Merge pull request #90 from AutoMaker-Org/fix-agent-runner

feat: implement SDK session ID handling for conversation continuity
This commit is contained in:
Web Dev Cody
2025-12-14 17:49:40 -05:00
committed by GitHub
5 changed files with 40 additions and 71 deletions

View File

@@ -7,10 +7,6 @@
import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { BaseProvider } from "./base-provider.js"; import { BaseProvider } from "./base-provider.js";
import {
convertHistoryToMessages,
normalizeContentBlocks,
} from "../lib/conversation-utils.js";
import type { import type {
ExecuteOptions, ExecuteOptions,
ProviderMessage, ProviderMessage,
@@ -38,6 +34,7 @@ export class ClaudeProvider extends BaseProvider {
allowedTools, allowedTools,
abortController, abortController,
conversationHistory, conversationHistory,
sdkSessionId,
} = options; } = options;
// Build Claude SDK options // Build Claude SDK options
@@ -65,69 +62,17 @@ export class ClaudeProvider extends BaseProvider {
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
}, },
abortController, abortController,
// Resume existing SDK session if we have a session ID
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
? { resume: sdkSessionId }
: {}),
}; };
// Build prompt payload with conversation history // Build prompt payload
let promptPayload: string | AsyncGenerator<any, void, unknown> | Array<any>; let promptPayload: string | AsyncIterable<any>;
if (conversationHistory && conversationHistory.length > 0) { if (Array.isArray(prompt)) {
// Multi-turn conversation with history // Multi-part prompt (with images)
// Convert history to SDK message format
// Note: When using async generator, SDK only accepts SDKUserMessage (type: 'user')
// So we filter to only include user messages to avoid SDK errors
const historyMessages = convertHistoryToMessages(conversationHistory);
const hasAssistantMessages = historyMessages.some(
(msg) => msg.type === "assistant"
);
if (hasAssistantMessages) {
// If we have assistant messages, use async generator but filter to only user messages
// This maintains conversation flow while respecting SDK type constraints
promptPayload = (async function* () {
// Filter to only user messages - SDK async generator only accepts SDKUserMessage
const userHistoryMessages = historyMessages.filter(
(msg) => msg.type === "user"
);
for (const msg of userHistoryMessages) {
yield msg;
}
// Yield current prompt
const normalizedPrompt = normalizeContentBlocks(prompt);
const currentPrompt = {
type: "user" as const,
session_id: "",
message: {
role: "user" as const,
content: normalizedPrompt,
},
parent_tool_use_id: null,
};
yield currentPrompt;
})();
} else {
// Only user messages in history - can use async generator normally
promptPayload = (async function* () {
for (const msg of historyMessages) {
yield msg;
}
// Yield current prompt
const normalizedPrompt = normalizeContentBlocks(prompt);
const currentPrompt = {
type: "user" as const,
session_id: "",
message: {
role: "user" as const,
content: normalizedPrompt,
},
parent_tool_use_id: null,
};
yield currentPrompt;
})();
}
} else if (Array.isArray(prompt)) {
// Multi-part prompt (with images) - no history
promptPayload = (async function* () { promptPayload = (async function* () {
const multiPartPrompt = { const multiPartPrompt = {
type: "user" as const, type: "user" as const,
@@ -141,7 +86,7 @@ export class ClaudeProvider extends BaseProvider {
yield multiPartPrompt; yield multiPartPrompt;
})(); })();
} else { } else {
// Simple text prompt - no history // Simple text prompt
promptPayload = prompt; promptPayload = prompt;
} }

View File

@@ -32,6 +32,7 @@ export interface ExecuteOptions {
mcpServers?: Record<string, unknown>; mcpServers?: Record<string, unknown>;
abortController?: AbortController; abortController?: AbortController;
conversationHistory?: ConversationMessage[]; // Previous messages for context conversationHistory?: ConversationMessage[]; // Previous messages for context
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
} }
/** /**

View File

@@ -33,6 +33,7 @@ interface Session {
abortController: AbortController | null; abortController: AbortController | null;
workingDirectory: string; workingDirectory: string;
model?: string; model?: string;
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
} }
interface SessionMetadata { interface SessionMetadata {
@@ -45,6 +46,7 @@ interface SessionMetadata {
archived?: boolean; archived?: boolean;
tags?: string[]; tags?: string[];
model?: string; model?: string;
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
} }
export class AgentService { export class AgentService {
@@ -75,11 +77,15 @@ export class AgentService {
}) { }) {
if (!this.sessions.has(sessionId)) { if (!this.sessions.has(sessionId)) {
const messages = await this.loadSession(sessionId); const messages = await this.loadSession(sessionId);
const metadata = await this.loadMetadata();
const sessionMetadata = metadata[sessionId];
this.sessions.set(sessionId, { this.sessions.set(sessionId, {
messages, messages,
isRunning: false, isRunning: false,
abortController: null, abortController: null,
workingDirectory: workingDirectory || process.cwd(), workingDirectory: workingDirectory || process.cwd(),
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
}); });
} }
@@ -200,6 +206,7 @@ export class AgentService {
abortController: session.abortController!, abortController: session.abortController!,
conversationHistory: conversationHistory:
conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory.length > 0 ? conversationHistory : undefined,
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
}; };
// Build prompt content with images // Build prompt content with images
@@ -221,6 +228,16 @@ export class AgentService {
const toolUses: Array<{ name: string; input: unknown }> = []; const toolUses: Array<{ name: string; input: unknown }> = [];
for await (const msg of stream) { for await (const msg of stream) {
// Capture SDK session ID from any message and persist it
if (msg.session_id && !session.sdkSessionId) {
session.sdkSessionId = msg.session_id;
console.log(
`[AgentService] Captured SDK session ID: ${msg.session_id}`
);
// Persist the SDK session ID to ensure conversation continuity across server restarts
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
}
if (msg.type === "assistant") { if (msg.type === "assistant") {
if (msg.message?.content) { if (msg.message?.content) {
for (const block of msg.message.content) { for (const block of msg.message.content) {

View File

@@ -160,7 +160,7 @@ describe("claude-provider.ts", () => {
}); });
}); });
it("should handle conversation history", async () => { it("should handle conversation history with sdkSessionId using resume option", async () => {
vi.mocked(sdk.query).mockReturnValue( vi.mocked(sdk.query).mockReturnValue(
(async function* () { (async function* () {
yield { type: "text", text: "test" }; yield { type: "text", text: "test" };
@@ -176,13 +176,18 @@ describe("claude-provider.ts", () => {
prompt: "Current message", prompt: "Current message",
cwd: "/test", cwd: "/test",
conversationHistory, conversationHistory,
sdkSessionId: "test-session-id",
}); });
await collectAsyncGenerator(generator); await collectAsyncGenerator(generator);
// Should pass an async generator as prompt // Should use resume option when sdkSessionId is provided with history
const callArgs = vi.mocked(sdk.query).mock.calls[0][0]; expect(sdk.query).toHaveBeenCalledWith({
expect(typeof callArgs.prompt).not.toBe("string"); prompt: "Current message",
options: expect.objectContaining({
resume: "test-session-id",
}),
});
}); });
it("should handle array prompt (with images)", async () => { it("should handle array prompt (with images)", async () => {

View File

@@ -103,8 +103,9 @@ describe("agent-service.ts", () => {
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
// Should only read file once // First call reads session file and metadata file (2 calls)
expect(fs.readFile).toHaveBeenCalledTimes(1); // Second call should reuse in-memory session (no additional calls)
expect(fs.readFile).toHaveBeenCalledTimes(2);
}); });
}); });