mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(acp): add resumeSessionId to sessions_spawn for ACP session resume (#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 (#41847) (thanks @pejmanjohn) --------- Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com> Co-authored-by: Onur <onur@textcortex.com>
This commit is contained in:
committed by
GitHub
parent
c2eb12bbc5
commit
aca216bfcf
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -234,6 +234,7 @@ export class AcpSessionManager {
|
||||
sessionKey,
|
||||
agent,
|
||||
mode: input.mode,
|
||||
resumeSessionId: input.resumeSessionId,
|
||||
cwd: requestedCwd,
|
||||
}),
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
|
||||
@@ -43,6 +43,7 @@ export type AcpInitializeSessionInput = {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
resumeSessionId?: string;
|
||||
cwd?: string;
|
||||
backendId?: string;
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ export type AcpRuntimeEnsureInput = {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
resumeSessionId?: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user