diff --git a/src/acp/client.ts b/src/acp/client.ts index a919528e..051efdd7 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -622,18 +622,38 @@ export class AcpClient { return { readable, writable }; } - async createSession(cwd = this.options.cwd): Promise { + async createSession( + cwd = this.options.cwd, + hints?: { proposedSessionId?: string }, + ): Promise { const connection = this.getConnection(); const { command, args } = splitCommandLine(this.options.agentCommand); const claudeAcp = isClaudeAcpCommand(command, args); + let sessionMeta = buildClaudeCodeOptionsMeta(this.options.sessionOptions); + if (hints?.proposedSessionId) { + sessionMeta = sessionMeta ?? {}; + sessionMeta = { + ...sessionMeta, + claudeCode: { + ...(sessionMeta.claudeCode as Record | undefined), + options: { + ...((sessionMeta.claudeCode as Record | undefined)?.options as + | Record + | undefined), + proposedSessionId: hints.proposedSessionId, + }, + }, + }; + } + let result: Awaited>; try { const createPromise = this.runConnectionRequest(() => connection.newSession({ cwd: asAbsoluteCwd(cwd), mcpServers: this.options.mcpServers ?? [], - _meta: buildClaudeCodeOptionsMeta(this.options.sessionOptions), + _meta: sessionMeta, }), ); result = claudeAcp diff --git a/src/runtime/engine/manager.ts b/src/runtime/engine/manager.ts index ffb8ce00..710d123e 100644 --- a/src/runtime/engine/manager.ts +++ b/src/runtime/engine/manager.ts @@ -254,6 +254,45 @@ export class AcpRuntimeManager { return existing; } + // For oneshot sessions with a resumeSessionId, skip client.start() + loadSession entirely. + // Running start+loadSession+close writes a closed/interrupted marker to the JSONL, which + // causes connectAndLoadSession (phase 2) to fail when it tries to resume the session. + // Instead, save a minimal record with the resume UUID and return early. + // Phase 2 handles the actual session/load on its own. + if (input.mode === "oneshot" && input.resumeSessionId) { + const resumeUUID = input.resumeSessionId.split(":").pop()!; + const record = createInitialRecord({ + recordId: createRecordId(input.sessionKey, input.mode), + sessionName: input.sessionKey, + sessionId: resumeUUID, + agentCommand, + cwd, + agentSessionId: undefined, + }); + await this.options.sessionStore.save(record); + return record; + } + + // For oneshot second turns arriving without an explicit resumeSessionId (e.g. a follow-up + // message via dispatch-acp after a completed turn), the caller does not know to pass + // resumeSessionId because persistedResumeSessionId is undefined in oneshot mode. + // The normal path would call createSession(cwd, { proposedSessionId: UUID }) but the JSONL + // already exists from the first turn, causing Claude Code to fail with a session-init error. + // Fix: if the existing record has an acpSessionId (set during the first turn), use it as an + // implicit resume — same early-return pattern as above, without requiring an explicit hint. + if (input.mode === "oneshot" && !input.resumeSessionId && existing?.acpSessionId) { + const record = createInitialRecord({ + recordId: createRecordId(input.sessionKey, input.mode), + sessionName: input.sessionKey, + sessionId: existing.acpSessionId, + agentCommand, + cwd, + agentSessionId: undefined, + }); + await this.options.sessionStore.save(record); + return record; + } + const client = this.createClient({ agentCommand, cwd, @@ -273,7 +312,10 @@ export class AcpRuntimeManager { sessionId = input.resumeSessionId; agentSessionId = loaded.agentSessionId; } else { - const created = await client.createSession(cwd); + // Pass sessionKey UUID as proposedSessionId so Claude Code uses the same UUID + // as OpenClaw's childSessionKey, making session files predictable and resumable. + const sessionKeyUUID = input.sessionKey.split(":").pop()!; + const created = await client.createSession(cwd, { proposedSessionId: sessionKeyUUID }); sessionId = created.sessionId; agentSessionId = created.agentSessionId; } diff --git a/src/runtime/engine/reconnect.ts b/src/runtime/engine/reconnect.ts index 40702744..236031d3 100644 --- a/src/runtime/engine/reconnect.ts +++ b/src/runtime/engine/reconnect.ts @@ -262,7 +262,12 @@ export async function connectAndLoadSession( if (!shouldFallbackToNewSession(error, record)) { throw error; } - const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs); + // Pass acpSessionId as proposedSessionId so the UUID stays stable when + // falling back from session/load to session/new. + const createdSession = await withTimeout( + client.createSession(record.cwd, { proposedSessionId: record.acpSessionId }), + options.timeoutMs, + ); sessionId = createdSession.sessionId; createdFreshSession = true; pendingAgentSessionId = createdSession.agentSessionId; @@ -275,7 +280,12 @@ export async function connectAndLoadSession( reason: "agent does not support session/load", }); } - const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs); + // Pass acpSessionId as proposedSessionId so the UUID stays stable when + // the agent does not support session/load. + const createdSession = await withTimeout( + client.createSession(record.cwd, { proposedSessionId: record.acpSessionId }), + options.timeoutMs, + ); sessionId = createdSession.sessionId; createdFreshSession = true; pendingAgentSessionId = createdSession.agentSessionId; diff --git a/test/client.test.ts b/test/client.test.ts index 376081ae..6694ed8d 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -455,6 +455,72 @@ test("AcpClient createSession forwards codex model metadata without setting it e assert.equal(setConfigCalled, false); }); +test("AcpClient createSession includes proposedSessionId in _meta when provided via hints", async () => { + const proposedId = "22222222-2222-2222-2222-222222222222"; + const client = makeClient({ + sessionOptions: { model: "sonnet" }, + }); + + let capturedParams: Record | undefined; + asInternals(client).connection = { + newSession: async (params: Record) => { + capturedParams = params; + return { sessionId: "session-hints" }; + }, + }; + + const result = await client.createSession("/tmp/acpx-hints", { proposedSessionId: proposedId }); + assert.equal(result.sessionId, "session-hints"); + assert.deepEqual(capturedParams, { + cwd: "/tmp/acpx-hints", + mcpServers: [], + _meta: { + claudeCode: { + options: { + model: "sonnet", + proposedSessionId: proposedId, + }, + }, + }, + }); +}); + +test("AcpClient createSession does not include proposedSessionId in _meta when hints are absent", async () => { + const client = makeClient({ + sessionOptions: { model: "sonnet" }, + }); + + let capturedParams: Record | undefined; + asInternals(client).connection = { + newSession: async (params: Record) => { + capturedParams = params; + return { sessionId: "session-no-hints" }; + }, + }; + + const result = await client.createSession("/tmp/acpx-no-hints"); + assert.equal(result.sessionId, "session-no-hints"); + assert.deepEqual(capturedParams, { + cwd: "/tmp/acpx-no-hints", + mcpServers: [], + _meta: { + claudeCode: { + options: { + model: "sonnet", + }, + }, + }, + }); + // proposedSessionId must not appear anywhere in the captured params + const metaOptions = (capturedParams?._meta as Record | undefined)?.claudeCode as + | Record + | undefined; + assert.equal( + Object.prototype.hasOwnProperty.call(metaOptions?.options, "proposedSessionId"), + false, + ); +}); + test("AcpClient setSessionModel uses session/set_model", async () => { const client = makeClient(); diff --git a/test/runtime-manager.test.ts b/test/runtime-manager.test.ts index 4e94f01f..a60a5615 100644 --- a/test/runtime-manager.test.ts +++ b/test/runtime-manager.test.ts @@ -20,7 +20,10 @@ type FakeClient = { }; start: () => Promise; close: () => Promise; - createSession: (cwd: string) => Promise<{ sessionId: string; agentSessionId?: string }>; + createSession: ( + cwd: string, + hints?: { proposedSessionId?: string }, + ) => Promise<{ sessionId: string; agentSessionId?: string }>; loadSession: (sessionId: string, cwd: string) => Promise<{ agentSessionId?: string }>; hasReusableSession: (sessionId: string) => boolean; supportsLoadSession: () => boolean; @@ -1332,3 +1335,94 @@ test("AcpRuntimeManager falls back when a kept-open persistent client is no long assert.equal(secondClientPromptCalls, 1); assert.equal(constructed, 2); }); + +test("AcpRuntimeManager passes proposedSessionId derived from session key when creating a new session", async () => { + const store = new InMemorySessionStore(); + let capturedHints: { proposedSessionId?: string } | undefined; + const manager = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/workspace", sessionStore: store }), + { + clientFactory: () => + ({ + initializeResult: { protocolVersion: 1, agentCapabilities: {} }, + start: async () => {}, + close: async () => {}, + createSession: async (_cwd: string, hints?: { proposedSessionId?: string }) => { + capturedHints = hints; + return { sessionId: "new-session", agentSessionId: "agent-session" }; + }, + loadSession: async () => ({ agentSessionId: "unused" }), + hasReusableSession: () => false, + supportsLoadSession: () => true, + loadSessionWithOptions: async () => ({ agentSessionId: "unused" }), + getAgentLifecycleSnapshot: () => ({ running: true }), + prompt: async () => ({ stopReason: "end_turn" }), + requestCancelActivePrompt: async () => false, + hasActivePrompt: () => false, + setSessionMode: async () => {}, + setSessionConfigOption: async () => {}, + clearEventHandlers: () => {}, + setEventHandlers: () => {}, + }) as never, + }, + ); + + await manager.ensureSession({ + sessionKey: "agent:claude:acp:33333333-3333-3333-3333-333333333333", + agent: "codex", + mode: "persistent", + cwd: "/workspace", + }); + + assert.deepEqual(capturedHints, { + proposedSessionId: "33333333-3333-3333-3333-333333333333", + }); +}); + +test("AcpRuntimeManager does not start a Claude process for oneshot resume", async () => { + const store = new InMemorySessionStore(); + let startCalls = 0; + let loadSessionCalls = 0; + const resumeUUID = "44444444-4444-4444-4444-444444444444"; + const manager = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/tmp", sessionStore: store }), + { + clientFactory: () => + ({ + initializeResult: { protocolVersion: 1, agentCapabilities: {} }, + start: async () => { + startCalls += 1; + }, + close: async () => {}, + createSession: async () => ({ sessionId: "unused", agentSessionId: "unused" }), + loadSession: async () => { + loadSessionCalls += 1; + return { agentSessionId: "unused" }; + }, + hasReusableSession: () => false, + supportsLoadSession: () => true, + loadSessionWithOptions: async () => ({ agentSessionId: "unused" }), + getAgentLifecycleSnapshot: () => ({ running: true }), + prompt: async () => ({ stopReason: "end_turn" }), + requestCancelActivePrompt: async () => false, + hasActivePrompt: () => false, + setSessionMode: async () => {}, + setSessionConfigOption: async () => {}, + clearEventHandlers: () => {}, + setEventHandlers: () => {}, + }) as never, + }, + ); + + const record = await manager.ensureSession({ + sessionKey: `agent:claude:acp:${resumeUUID}`, + agent: "codex", + mode: "oneshot", + resumeSessionId: resumeUUID, + cwd: "/tmp", + }); + + assert.equal(startCalls, 0); + assert.equal(loadSessionCalls, 0); + assert.equal(record.acpSessionId, resumeUUID); +});