Skip to content

Commit 404b152

Browse files
authored
fix(acp): persist spawned child session history (#40137)
Merged via squash. Prepared head SHA: 62de5d5 Co-authored-by: mbelinky <[email protected]> Co-authored-by: mbelinky <[email protected]> Reviewed-by: @mbelinky
1 parent 72ebaf9 commit 404b152

File tree

7 files changed

+436
-30
lines changed

7 files changed

+436
-30
lines changed

.secrets.baseline

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10198,21 +10198,21 @@
1019810198
"filename": "docs/tools/web.md",
1019910199
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
1020010200
"is_verified": false,
10201-
"line_number": 131
10201+
"line_number": 135
1020210202
},
1020310203
{
1020410204
"type": "Secret Keyword",
1020510205
"filename": "docs/tools/web.md",
1020610206
"hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac",
1020710207
"is_verified": false,
10208-
"line_number": 224
10208+
"line_number": 228
1020910209
},
1021010210
{
1021110211
"type": "Secret Keyword",
1021210212
"filename": "docs/tools/web.md",
1021310213
"hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217",
1021410214
"is_verified": false,
10215-
"line_number": 328
10215+
"line_number": 332
1021610216
}
1021710217
],
1021810218
"docs/tts.md": [
@@ -13034,5 +13034,5 @@
1303413034
}
1303513035
]
1303613036
},
13037-
"generated_at": "2026-03-08T18:14:00Z"
13037+
"generated_at": "2026-03-08T18:30:57Z"
1303813038
}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
2626
- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.
2727
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
2828
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
29+
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
2930

3031
## 2026.3.7
3132

src/agents/acp-spawn.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ const hoisted = vi.hoisted(() => {
3535
const initializeSessionMock = vi.fn();
3636
const startAcpSpawnParentStreamRelayMock = vi.fn();
3737
const resolveAcpSpawnStreamLogPathMock = vi.fn();
38+
const loadSessionStoreMock = vi.fn();
39+
const resolveStorePathMock = vi.fn();
40+
const resolveSessionTranscriptFileMock = vi.fn();
3841
const state = {
3942
cfg: createDefaultSpawnConfig(),
4043
};
@@ -49,6 +52,9 @@ const hoisted = vi.hoisted(() => {
4952
initializeSessionMock,
5053
startAcpSpawnParentStreamRelayMock,
5154
resolveAcpSpawnStreamLogPathMock,
55+
loadSessionStoreMock,
56+
resolveStorePathMock,
57+
resolveSessionTranscriptFileMock,
5258
state,
5359
};
5460
});
@@ -86,6 +92,24 @@ vi.mock("../gateway/call.js", () => ({
8692
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
8793
}));
8894

95+
vi.mock("../config/sessions.js", async (importOriginal) => {
96+
const actual = await importOriginal<typeof import("../config/sessions.js")>();
97+
return {
98+
...actual,
99+
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
100+
resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts),
101+
};
102+
});
103+
104+
vi.mock("../config/sessions/transcript.js", async (importOriginal) => {
105+
const actual = await importOriginal<typeof import("../config/sessions/transcript.js")>();
106+
return {
107+
...actual,
108+
resolveSessionTranscriptFile: (params: unknown) =>
109+
hoisted.resolveSessionTranscriptFileMock(params),
110+
};
111+
});
112+
89113
vi.mock("../acp/control-plane/manager.js", () => {
90114
return {
91115
getAcpSessionManager: () => ({
@@ -263,6 +287,34 @@ describe("spawnAcpDirect", () => {
263287
hoisted.resolveAcpSpawnStreamLogPathMock
264288
.mockReset()
265289
.mockReturnValue("/tmp/sess-main.acp-stream.jsonl");
290+
hoisted.resolveStorePathMock.mockReset().mockReturnValue("/tmp/codex-sessions.json");
291+
hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => {
292+
const store: Record<string, { sessionId: string; updatedAt: number }> = {};
293+
return new Proxy(store, {
294+
get(_target, prop) {
295+
if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) {
296+
return { sessionId: "sess-123", updatedAt: Date.now() };
297+
}
298+
return undefined;
299+
},
300+
});
301+
});
302+
hoisted.resolveSessionTranscriptFileMock
303+
.mockReset()
304+
.mockImplementation(async (params: unknown) => {
305+
const typed = params as { threadId?: string };
306+
const sessionFile = typed.threadId
307+
? `/tmp/agents/codex/sessions/sess-123-topic-${typed.threadId}.jsonl`
308+
: "/tmp/agents/codex/sessions/sess-123.jsonl";
309+
return {
310+
sessionFile,
311+
sessionEntry: {
312+
sessionId: "sess-123",
313+
updatedAt: Date.now(),
314+
sessionFile,
315+
},
316+
};
317+
});
266318
});
267319

268320
it("spawns ACP session, binds a new thread, and dispatches initial task", async () => {
@@ -286,6 +338,13 @@ describe("spawnAcpDirect", () => {
286338
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
287339
expect(result.runId).toBe("run-1");
288340
expect(result.mode).toBe("session");
341+
const patchCalls = hoisted.callGatewayMock.mock.calls
342+
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
343+
.filter((request) => request.method === "sessions.patch");
344+
expect(patchCalls[0]?.params).toMatchObject({
345+
key: result.childSessionKey,
346+
spawnedBy: "agent:main:main",
347+
});
289348
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
290349
expect.objectContaining({
291350
targetKind: "session",
@@ -308,6 +367,12 @@ describe("spawnAcpDirect", () => {
308367
mode: "persistent",
309368
}),
310369
);
370+
const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map(
371+
(call: unknown[]) => call[0] as { threadId?: string },
372+
);
373+
expect(transcriptCalls).toHaveLength(2);
374+
expect(transcriptCalls[0]?.threadId).toBeUndefined();
375+
expect(transcriptCalls[1]?.threadId).toBe("child-thread");
311376
});
312377

313378
it("does not inline delivery for fresh oneshot ACP runs", async () => {
@@ -328,6 +393,13 @@ describe("spawnAcpDirect", () => {
328393

329394
expect(result.status).toBe("accepted");
330395
expect(result.mode).toBe("run");
396+
expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith(
397+
expect.objectContaining({
398+
sessionId: "sess-123",
399+
storePath: "/tmp/codex-sessions.json",
400+
agentId: "codex",
401+
}),
402+
);
331403
const agentCall = hoisted.callGatewayMock.mock.calls
332404
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
333405
.find((request) => request.method === "agent");
@@ -337,6 +409,32 @@ describe("spawnAcpDirect", () => {
337409
expect(agentCall?.params?.threadId).toBeUndefined();
338410
});
339411

412+
it("keeps ACP spawn running when session-file persistence fails", async () => {
413+
hoisted.resolveSessionTranscriptFileMock.mockRejectedValueOnce(new Error("disk full"));
414+
415+
const result = await spawnAcpDirect(
416+
{
417+
task: "Investigate flaky tests",
418+
agentId: "codex",
419+
mode: "run",
420+
},
421+
{
422+
agentSessionKey: "agent:main:main",
423+
agentChannel: "telegram",
424+
agentAccountId: "default",
425+
agentTo: "telegram:6098642967",
426+
agentThreadId: "1",
427+
},
428+
);
429+
430+
expect(result.status).toBe("accepted");
431+
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
432+
const agentCall = hoisted.callGatewayMock.mock.calls
433+
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
434+
.find((request) => request.method === "agent");
435+
expect(agentCall?.params?.sessionKey).toBe(result.childSessionKey);
436+
});
437+
340438
it("includes cwd in ACP thread intro banner when provided at spawn time", async () => {
341439
const result = await spawnAcpDirect(
342440
{

src/agents/acp-spawn.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@ import {
2323
} from "../channels/thread-bindings-policy.js";
2424
import { loadConfig } from "../config/config.js";
2525
import type { OpenClawConfig } from "../config/config.js";
26+
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
27+
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js";
2628
import { callGateway } from "../gateway/call.js";
2729
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
2830
import {
2931
getSessionBindingService,
3032
isSessionBindingError,
3133
type SessionBindingRecord,
3234
} from "../infra/outbound/session-binding-service.js";
35+
import { createSubsystemLogger } from "../logging/subsystem.js";
3336
import { normalizeAgentId } from "../routing/session-key.js";
3437
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
3538
import {
@@ -38,6 +41,9 @@ import {
3841
startAcpSpawnParentStreamRelay,
3942
} from "./acp-spawn-parent-stream.js";
4043
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
44+
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
45+
46+
const log = createSubsystemLogger("agents/acp-spawn");
4147

4248
export const ACP_SPAWN_MODES = ["run", "session"] as const;
4349
export type SpawnAcpMode = (typeof ACP_SPAWN_MODES)[number];
@@ -162,6 +168,50 @@ function summarizeError(err: unknown): string {
162168
return "error";
163169
}
164170

171+
function resolveRequesterInternalSessionKey(params: {
172+
cfg: OpenClawConfig;
173+
requesterSessionKey?: string;
174+
}): string {
175+
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
176+
const requesterSessionKey = params.requesterSessionKey?.trim();
177+
return requesterSessionKey
178+
? resolveInternalSessionKey({
179+
key: requesterSessionKey,
180+
alias,
181+
mainKey,
182+
})
183+
: alias;
184+
}
185+
186+
async function persistAcpSpawnSessionFileBestEffort(params: {
187+
sessionId: string;
188+
sessionKey: string;
189+
sessionEntry: SessionEntry | undefined;
190+
sessionStore: Record<string, SessionEntry>;
191+
storePath: string;
192+
agentId: string;
193+
threadId?: string | number;
194+
stage: "spawn" | "thread-bind";
195+
}): Promise<SessionEntry | undefined> {
196+
try {
197+
const resolvedSessionFile = await resolveSessionTranscriptFile({
198+
sessionId: params.sessionId,
199+
sessionKey: params.sessionKey,
200+
sessionEntry: params.sessionEntry,
201+
sessionStore: params.sessionStore,
202+
storePath: params.storePath,
203+
agentId: params.agentId,
204+
threadId: params.threadId,
205+
});
206+
return resolvedSessionFile.sessionEntry;
207+
} catch (error) {
208+
log.warn(
209+
`ACP session-file persistence failed during ${params.stage} for ${params.sessionKey}: ${summarizeError(error)}`,
210+
);
211+
return params.sessionEntry;
212+
}
213+
}
214+
165215
function resolveConversationIdForThreadBinding(params: {
166216
to?: string;
167217
threadId?: string | number;
@@ -257,6 +307,10 @@ export async function spawnAcpDirect(
257307
ctx: SpawnAcpContext,
258308
): Promise<SpawnAcpResult> {
259309
const cfg = loadConfig();
310+
const requesterInternalKey = resolveRequesterInternalSessionKey({
311+
cfg,
312+
requesterSessionKey: ctx.agentSessionKey,
313+
});
260314
if (!isAcpEnabledByPolicy(cfg)) {
261315
return {
262316
status: "forbidden",
@@ -346,11 +400,27 @@ export async function spawnAcpDirect(
346400
method: "sessions.patch",
347401
params: {
348402
key: sessionKey,
403+
spawnedBy: requesterInternalKey,
349404
...(params.label ? { label: params.label } : {}),
350405
},
351406
timeoutMs: 10_000,
352407
});
353408
sessionCreated = true;
409+
const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId });
410+
const sessionStore = loadSessionStore(storePath);
411+
let sessionEntry: SessionEntry | undefined = sessionStore[sessionKey];
412+
const sessionId = sessionEntry?.sessionId;
413+
if (sessionId) {
414+
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
415+
sessionId,
416+
sessionKey,
417+
sessionStore,
418+
storePath,
419+
sessionEntry,
420+
agentId: targetAgentId,
421+
stage: "spawn",
422+
});
423+
}
354424
const initialized = await acpManager.initializeSession({
355425
cfg,
356426
sessionKey,
@@ -408,6 +478,21 @@ export async function spawnAcpDirect(
408478
`Failed to create and bind a ${preparedBinding.channel} thread for this ACP session.`,
409479
);
410480
}
481+
if (sessionId) {
482+
const boundThreadId = String(binding.conversation.conversationId).trim() || undefined;
483+
if (boundThreadId) {
484+
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
485+
sessionId,
486+
sessionKey,
487+
sessionStore,
488+
storePath,
489+
sessionEntry,
490+
agentId: targetAgentId,
491+
threadId: boundThreadId,
492+
stage: "thread-bind",
493+
});
494+
}
495+
}
411496
}
412497
} catch (err) {
413498
await cleanupFailedAcpSpawn({

0 commit comments

Comments
 (0)