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
This commit is contained in:
Pejman Pour-Moezzi
2026-03-09 23:30:17 -07:00
committed by Onur
parent c2eb12bbc5
commit e9a18b5ec7
8 changed files with 75 additions and 8 deletions

View File

@@ -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();

View File

@@ -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;

View File

@@ -234,6 +234,7 @@ export class AcpSessionManager {
sessionKey,
agent,
mode: input.mode,
resumeSessionId: input.resumeSessionId,
cwd: requestedCwd,
}),
fallbackCode: "ACP_SESSION_INIT_FAILED",

View File

@@ -43,6 +43,7 @@ export type AcpInitializeSessionInput = {
sessionKey: string;
agent: string;
mode: AcpRuntimeSessionMode;
resumeSessionId?: string;
cwd?: string;
backendId?: string;
};

View File

@@ -35,6 +35,7 @@ export type AcpRuntimeEnsureInput = {
sessionKey: string;
agent: string;
mode: AcpRuntimeSessionMode;
resumeSessionId?: string;
cwd?: string;
env?: Record<string, string>;
};

View File

@@ -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,
});

View File

@@ -163,6 +163,28 @@ 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 attachments for ACP runtime", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",

View File

@@ -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/). 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");
@@ -140,6 +147,7 @@ export function createSessionsSpawnTool(
task,
label: label || undefined,
agentId: requestedAgentId,
resumeSessionId,
cwd,
mode: mode && ACP_SPAWN_MODES.includes(mode) ? mode : undefined,
thread,