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 { BaseProvider } from "./base-provider.js";
import {
convertHistoryToMessages,
normalizeContentBlocks,
} from "../lib/conversation-utils.js";
import type {
ExecuteOptions,
ProviderMessage,
@@ -38,6 +34,7 @@ export class ClaudeProvider extends BaseProvider {
allowedTools,
abortController,
conversationHistory,
sdkSessionId,
} = options;
// Build Claude SDK options
@@ -65,69 +62,17 @@ export class ClaudeProvider extends BaseProvider {
autoAllowBashIfSandboxed: true,
},
abortController,
// Resume existing SDK session if we have a session ID
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
? { resume: sdkSessionId }
: {}),
};
// Build prompt payload with conversation history
let promptPayload: string | AsyncGenerator<any, void, unknown> | Array<any>;
// Build prompt payload
let promptPayload: string | AsyncIterable<any>;
if (conversationHistory && conversationHistory.length > 0) {
// Multi-turn conversation with history
// 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
if (Array.isArray(prompt)) {
// Multi-part prompt (with images)
promptPayload = (async function* () {
const multiPartPrompt = {
type: "user" as const,
@@ -141,7 +86,7 @@ export class ClaudeProvider extends BaseProvider {
yield multiPartPrompt;
})();
} else {
// Simple text prompt - no history
// Simple text prompt
promptPayload = prompt;
}

View File

@@ -32,6 +32,7 @@ export interface ExecuteOptions {
mcpServers?: Record<string, unknown>;
abortController?: AbortController;
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;
workingDirectory: string;
model?: string;
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
}
interface SessionMetadata {
@@ -45,6 +46,7 @@ interface SessionMetadata {
archived?: boolean;
tags?: string[];
model?: string;
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
}
export class AgentService {
@@ -75,11 +77,15 @@ export class AgentService {
}) {
if (!this.sessions.has(sessionId)) {
const messages = await this.loadSession(sessionId);
const metadata = await this.loadMetadata();
const sessionMetadata = metadata[sessionId];
this.sessions.set(sessionId, {
messages,
isRunning: false,
abortController: null,
workingDirectory: workingDirectory || process.cwd(),
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
});
}
@@ -200,6 +206,7 @@ export class AgentService {
abortController: session.abortController!,
conversationHistory:
conversationHistory.length > 0 ? conversationHistory : undefined,
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
};
// Build prompt content with images
@@ -221,6 +228,16 @@ export class AgentService {
const toolUses: Array<{ name: string; input: unknown }> = [];
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.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(
(async function* () {
yield { type: "text", text: "test" };
@@ -176,13 +176,18 @@ describe("claude-provider.ts", () => {
prompt: "Current message",
cwd: "/test",
conversationHistory,
sdkSessionId: "test-session-id",
});
await collectAsyncGenerator(generator);
// Should pass an async generator as prompt
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
expect(typeof callArgs.prompt).not.toBe("string");
// Should use resume option when sdkSessionId is provided with history
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Current message",
options: expect.objectContaining({
resume: "test-session-id",
}),
});
});
it("should handle array prompt (with images)", async () => {

View File

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