Skip to content

Commit aca216b

Browse files
pejmanjohnonutc
andauthored
feat(acp): add resumeSessionId to sessions_spawn for ACP session resume (openclaw#41847)
* feat(acp): add resumeSessionId to sessions_spawn for ACP session resume Thread resumeSessionId through the ACP session spawn pipeline so agents can resume existing sessions (e.g. a prior Codex conversation) instead of starting fresh. Flow: sessions_spawn tool → spawnAcpDirect → initializeSession → ensureSession → acpx --resume-session flag → agent session/load - Add resumeSessionId param to sessions-spawn-tool schema with description so agents can discover and use it - Thread through SpawnAcpParams → AcpInitializeSessionInput → AcpRuntimeEnsureInput → acpx extension runtime - Pass as --resume-session flag to acpx CLI - Error hard (exit 4) on non-existent session, no silent fallback - All new fields optional for backward compatibility Depends on acpx >= 0.1.16 (openclaw/acpx#85, merged, pending release). Tests: 26/26 pass (runtime + tool schema) Verified e2e: Discord → sessions_spawn(resumeSessionId) → Codex resumed session and recalled stored secret. 🤖 AI-assisted * fix: guard resumeSessionId against non-ACP runtime Add early-return error when resumeSessionId is passed without runtime="acp" (mirrors existing streamTo guard). Without this, the parameter is silently ignored and the agent gets a fresh session instead of resuming. Also update schema description to note the runtime=acp requirement. Addresses Greptile review feedback. * ACP: add changelog entry for session resume (openclaw#41847) (thanks @pejmanjohn) --------- Co-authored-by: Pejman Pour-Moezzi <[email protected]> Co-authored-by: Onur <[email protected]>
1 parent c2eb12b commit aca216b

File tree

9 files changed

+98
-8
lines changed

9 files changed

+98
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
77
### Changes
88

99
- 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.
10+
- 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.
1011

1112
### Breaking
1213

extensions/acpx/src/runtime.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,32 @@ describe("AcpxRuntime", () => {
127127
expect(promptArgs).toContain("--approve-all");
128128
});
129129

130+
it("uses sessions new with --resume-session when resumeSessionId is provided", async () => {
131+
const { runtime, logPath } = await createMockRuntimeFixture();
132+
const resumeSessionId = "sid-resume-123";
133+
const sessionKey = "agent:codex:acp:resume";
134+
const handle = await runtime.ensureSession({
135+
sessionKey,
136+
agent: "codex",
137+
mode: "persistent",
138+
resumeSessionId,
139+
});
140+
141+
expect(handle.backend).toBe("acpx");
142+
expect(handle.acpxRecordId).toBe("rec-" + sessionKey);
143+
144+
const logs = await readMockRuntimeLogEntries(logPath);
145+
expect(logs.some((entry) => entry.kind === "ensure")).toBe(false);
146+
const resumeEntry = logs.find(
147+
(entry) => entry.kind === "new" && String(entry.sessionName ?? "") === sessionKey,
148+
);
149+
expect(resumeEntry).toBeDefined();
150+
const resumeArgs = (resumeEntry?.args as string[]) ?? [];
151+
const resumeFlagIndex = resumeArgs.indexOf("--resume-session");
152+
expect(resumeFlagIndex).toBeGreaterThanOrEqual(0);
153+
expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId);
154+
});
155+
130156
it("serializes text plus image attachments into ACP prompt blocks", async () => {
131157
const { runtime, logPath } = await createMockRuntimeFixture();
132158

extensions/acpx/src/runtime.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,14 @@ export class AcpxRuntime implements AcpRuntime {
203203
}
204204
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
205205
const mode = input.mode;
206+
const resumeSessionId = asTrimmedString(input.resumeSessionId);
207+
const ensureSubcommand = resumeSessionId
208+
? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId]
209+
: ["sessions", "ensure", "--name", sessionName];
206210
const ensureCommand = await this.buildVerbArgs({
207211
agent,
208212
cwd,
209-
command: ["sessions", "ensure", "--name", sessionName],
213+
command: ensureSubcommand,
210214
});
211215

212216
let events = await this.runControlCommand({
@@ -221,7 +225,7 @@ export class AcpxRuntime implements AcpRuntime {
221225
asOptionalString(event.acpxRecordId),
222226
);
223227

224-
if (!ensuredEvent) {
228+
if (!ensuredEvent && !resumeSessionId) {
225229
const newCommand = await this.buildVerbArgs({
226230
agent,
227231
cwd,
@@ -238,12 +242,14 @@ export class AcpxRuntime implements AcpRuntime {
238242
asOptionalString(event.acpxSessionId) ||
239243
asOptionalString(event.acpxRecordId),
240244
);
241-
if (!ensuredEvent) {
242-
throw new AcpRuntimeError(
243-
"ACP_SESSION_INIT_FAILED",
244-
`ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`,
245-
);
246-
}
245+
}
246+
if (!ensuredEvent) {
247+
throw new AcpRuntimeError(
248+
"ACP_SESSION_INIT_FAILED",
249+
resumeSessionId
250+
? `ACP session init failed: 'sessions new --resume-session' returned no session identifiers for ${sessionName}.`
251+
: `ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`,
252+
);
247253
}
248254

249255
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;

src/acp/control-plane/manager.core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export class AcpSessionManager {
234234
sessionKey,
235235
agent,
236236
mode: input.mode,
237+
resumeSessionId: input.resumeSessionId,
237238
cwd: requestedCwd,
238239
}),
239240
fallbackCode: "ACP_SESSION_INIT_FAILED",

src/acp/control-plane/manager.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type AcpInitializeSessionInput = {
4343
sessionKey: string;
4444
agent: string;
4545
mode: AcpRuntimeSessionMode;
46+
resumeSessionId?: string;
4647
cwd?: string;
4748
backendId?: string;
4849
};

src/acp/runtime/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type AcpRuntimeEnsureInput = {
3535
sessionKey: string;
3636
agent: string;
3737
mode: AcpRuntimeSessionMode;
38+
resumeSessionId?: string;
3839
cwd?: string;
3940
env?: Record<string, string>;
4041
};

src/agents/acp-spawn.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type SpawnAcpParams = {
5656
task: string;
5757
label?: string;
5858
agentId?: string;
59+
resumeSessionId?: string;
5960
cwd?: string;
6061
mode?: SpawnAcpMode;
6162
thread?: boolean;
@@ -426,6 +427,7 @@ export async function spawnAcpDirect(
426427
sessionKey,
427428
agent: targetAgentId,
428429
mode: runtimeMode,
430+
resumeSessionId: params.resumeSessionId,
429431
cwd: params.cwd,
430432
backendId: cfg.acp?.backend,
431433
});

src/agents/tools/sessions-spawn-tool.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,43 @@ describe("sessions_spawn tool", () => {
163163
);
164164
});
165165

166+
it("passes resumeSessionId through to ACP spawns", async () => {
167+
const tool = createSessionsSpawnTool({
168+
agentSessionKey: "agent:main:main",
169+
});
170+
171+
await tool.execute("call-2c", {
172+
runtime: "acp",
173+
task: "resume prior work",
174+
agentId: "codex",
175+
resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4",
176+
});
177+
178+
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
179+
expect.objectContaining({
180+
task: "resume prior work",
181+
agentId: "codex",
182+
resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4",
183+
}),
184+
expect.any(Object),
185+
);
186+
});
187+
188+
it("rejects resumeSessionId without runtime=acp", async () => {
189+
const tool = createSessionsSpawnTool({
190+
agentSessionKey: "agent:main:main",
191+
});
192+
193+
const result = await tool.execute("call-guard", {
194+
task: "resume prior work",
195+
resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4",
196+
});
197+
198+
expect(JSON.stringify(result)).toContain("resumeSessionId is only supported for runtime=acp");
199+
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
200+
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
201+
});
202+
166203
it("rejects attachments for ACP runtime", async () => {
167204
const tool = createSessionsSpawnTool({
168205
agentSessionKey: "agent:main:main",

src/agents/tools/sessions-spawn-tool.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ const SessionsSpawnToolSchema = Type.Object({
2525
label: Type.Optional(Type.String()),
2626
runtime: optionalStringEnum(SESSIONS_SPAWN_RUNTIMES),
2727
agentId: Type.Optional(Type.String()),
28+
resumeSessionId: Type.Optional(
29+
Type.String({
30+
description:
31+
'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.',
32+
}),
33+
),
2834
model: Type.Optional(Type.String()),
2935
thinking: Type.Optional(Type.String()),
3036
cwd: Type.Optional(Type.String()),
@@ -91,6 +97,7 @@ export function createSessionsSpawnTool(
9197
const label = typeof params.label === "string" ? params.label.trim() : "";
9298
const runtime = params.runtime === "acp" ? "acp" : "subagent";
9399
const requestedAgentId = readStringParam(params, "agentId");
100+
const resumeSessionId = readStringParam(params, "resumeSessionId");
94101
const modelOverride = readStringParam(params, "model");
95102
const thinkingOverrideRaw = readStringParam(params, "thinking");
96103
const cwd = readStringParam(params, "cwd");
@@ -127,6 +134,13 @@ export function createSessionsSpawnTool(
127134
});
128135
}
129136

137+
if (resumeSessionId && runtime !== "acp") {
138+
return jsonResult({
139+
status: "error",
140+
error: `resumeSessionId is only supported for runtime=acp; got runtime=${runtime}`,
141+
});
142+
}
143+
130144
if (runtime === "acp") {
131145
if (Array.isArray(attachments) && attachments.length > 0) {
132146
return jsonResult({
@@ -140,6 +154,7 @@ export function createSessionsSpawnTool(
140154
task,
141155
label: label || undefined,
142156
agentId: requestedAgentId,
157+
resumeSessionId,
143158
cwd,
144159
mode: mode && ACP_SPAWN_MODES.includes(mode) ? mode : undefined,
145160
thread,

0 commit comments

Comments
 (0)