diff --git a/CHANGELOG.md b/CHANGELOG.md index 3168f5c544c..5a63e369f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky. +- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn. ### Breaking diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 38137b3f581..60ad7f49082 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -127,6 +127,32 @@ describe("AcpxRuntime", () => { expect(promptArgs).toContain("--approve-all"); }); + it("uses sessions new with --resume-session when resumeSessionId is provided", async () => { + const { runtime, logPath } = await createMockRuntimeFixture(); + const resumeSessionId = "sid-resume-123"; + const sessionKey = "agent:codex:acp:resume"; + const handle = await runtime.ensureSession({ + sessionKey, + agent: "codex", + mode: "persistent", + resumeSessionId, + }); + + expect(handle.backend).toBe("acpx"); + expect(handle.acpxRecordId).toBe("rec-" + sessionKey); + + const logs = await readMockRuntimeLogEntries(logPath); + expect(logs.some((entry) => entry.kind === "ensure")).toBe(false); + const resumeEntry = logs.find( + (entry) => entry.kind === "new" && String(entry.sessionName ?? "") === sessionKey, + ); + expect(resumeEntry).toBeDefined(); + const resumeArgs = (resumeEntry?.args as string[]) ?? []; + const resumeFlagIndex = resumeArgs.indexOf("--resume-session"); + expect(resumeFlagIndex).toBeGreaterThanOrEqual(0); + expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId); + }); + it("serializes text plus image attachments into ACP prompt blocks", async () => { const { runtime, logPath } = await createMockRuntimeFixture(); diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 7e310638699..b1d33a64f09 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -203,10 +203,14 @@ export class AcpxRuntime implements AcpRuntime { } const cwd = asTrimmedString(input.cwd) || this.config.cwd; const mode = input.mode; + const resumeSessionId = asTrimmedString(input.resumeSessionId); + const ensureSubcommand = resumeSessionId + ? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId] + : ["sessions", "ensure", "--name", sessionName]; const ensureCommand = await this.buildVerbArgs({ agent, cwd, - command: ["sessions", "ensure", "--name", sessionName], + command: ensureSubcommand, }); let events = await this.runControlCommand({ @@ -221,7 +225,7 @@ export class AcpxRuntime implements AcpRuntime { asOptionalString(event.acpxRecordId), ); - if (!ensuredEvent) { + if (!ensuredEvent && !resumeSessionId) { const newCommand = await this.buildVerbArgs({ agent, cwd, @@ -238,12 +242,14 @@ export class AcpxRuntime implements AcpRuntime { asOptionalString(event.acpxSessionId) || asOptionalString(event.acpxRecordId), ); - if (!ensuredEvent) { - throw new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`, - ); - } + } + if (!ensuredEvent) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + resumeSessionId + ? `ACP session init failed: 'sessions new --resume-session' returned no session identifiers for ${sessionName}.` + : `ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`, + ); } const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined; diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index f511355ae87..558e1ca24a8 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -234,6 +234,7 @@ export class AcpSessionManager { sessionKey, agent, mode: input.mode, + resumeSessionId: input.resumeSessionId, cwd: requestedCwd, }), fallbackCode: "ACP_SESSION_INIT_FAILED", diff --git a/src/acp/control-plane/manager.types.ts b/src/acp/control-plane/manager.types.ts index 33c2355305c..a2989c0d0f2 100644 --- a/src/acp/control-plane/manager.types.ts +++ b/src/acp/control-plane/manager.types.ts @@ -43,6 +43,7 @@ export type AcpInitializeSessionInput = { sessionKey: string; agent: string; mode: AcpRuntimeSessionMode; + resumeSessionId?: string; cwd?: string; backendId?: string; }; diff --git a/src/acp/runtime/types.ts b/src/acp/runtime/types.ts index 2d4b10ccf2c..b46f264b92d 100644 --- a/src/acp/runtime/types.ts +++ b/src/acp/runtime/types.ts @@ -35,6 +35,7 @@ export type AcpRuntimeEnsureInput = { sessionKey: string; agent: string; mode: AcpRuntimeSessionMode; + resumeSessionId?: string; cwd?: string; env?: Record; }; diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index c08cca8fcf8..5d305b25f27 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -56,6 +56,7 @@ export type SpawnAcpParams = { task: string; label?: string; agentId?: string; + resumeSessionId?: string; cwd?: string; mode?: SpawnAcpMode; thread?: boolean; @@ -426,6 +427,7 @@ export async function spawnAcpDirect( sessionKey, agent: targetAgentId, mode: runtimeMode, + resumeSessionId: params.resumeSessionId, cwd: params.cwd, backendId: cfg.acp?.backend, }); diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 01568462912..4fe106a7ebd 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -163,6 +163,43 @@ describe("sessions_spawn tool", () => { ); }); + it("passes resumeSessionId through to ACP spawns", async () => { + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + }); + + await tool.execute("call-2c", { + runtime: "acp", + task: "resume prior work", + agentId: "codex", + resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4", + }); + + expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith( + expect.objectContaining({ + task: "resume prior work", + agentId: "codex", + resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4", + }), + expect.any(Object), + ); + }); + + it("rejects resumeSessionId without runtime=acp", async () => { + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + }); + + const result = await tool.execute("call-guard", { + task: "resume prior work", + resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4", + }); + + expect(JSON.stringify(result)).toContain("resumeSessionId is only supported for runtime=acp"); + expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled(); + expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled(); + }); + it("rejects attachments for ACP runtime", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index b2214f6bc70..b735084d2b0 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -25,6 +25,12 @@ const SessionsSpawnToolSchema = Type.Object({ label: Type.Optional(Type.String()), runtime: optionalStringEnum(SESSIONS_SPAWN_RUNTIMES), agentId: Type.Optional(Type.String()), + resumeSessionId: Type.Optional( + Type.String({ + description: + 'Resume an existing agent session by its ID (e.g. a Codex session UUID from ~/.codex/sessions/). Requires runtime="acp". The agent replays conversation history via session/load instead of starting fresh.', + }), + ), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), cwd: Type.Optional(Type.String()), @@ -91,6 +97,7 @@ export function createSessionsSpawnTool( const label = typeof params.label === "string" ? params.label.trim() : ""; const runtime = params.runtime === "acp" ? "acp" : "subagent"; const requestedAgentId = readStringParam(params, "agentId"); + const resumeSessionId = readStringParam(params, "resumeSessionId"); const modelOverride = readStringParam(params, "model"); const thinkingOverrideRaw = readStringParam(params, "thinking"); const cwd = readStringParam(params, "cwd"); @@ -127,6 +134,13 @@ export function createSessionsSpawnTool( }); } + if (resumeSessionId && runtime !== "acp") { + return jsonResult({ + status: "error", + error: `resumeSessionId is only supported for runtime=acp; got runtime=${runtime}`, + }); + } + if (runtime === "acp") { if (Array.isArray(attachments) && attachments.length > 0) { return jsonResult({ @@ -140,6 +154,7 @@ export function createSessionsSpawnTool( task, label: label || undefined, agentId: requestedAgentId, + resumeSessionId, cwd, mode: mode && ACP_SPAWN_MODES.includes(mode) ? mode : undefined, thread,