mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge pull request #90 from AutoMaker-Org/fix-agent-runner
feat: implement SDK session ID handling for conversation continuity
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user