Skip to content

Commit aeb7d07

Browse files
hclhclsys
andauthored
fix(cli-runner): gate raw transcript reseed
Summary: - Gate raw transcript reseeding behind an explicit CLI backend opt-in. - Keep auth-profile and auth-epoch invalidations from replaying raw transcript history. - Add regression coverage, docs, config schema/baseline, and changelog entry for #79713. Verification: - pnpm exec oxfmt --check --threads=1 CHANGELOG.md docs/gateway/cli-backends.md docs/gateway/config-agents.md src/agents/cli-runner.reliability.test.ts src/agents/cli-runner/prepare.test.ts src/agents/cli-runner/prepare.ts src/agents/cli-runner/session-history.test.ts src/agents/cli-runner/session-history.ts src/config/types.agent-defaults.ts src/config/zod-schema.core.ts - pnpm run lint:extensions:bundled - pnpm deadcode:dependencies - pnpm test src/agents/cli-runner/session-history.test.ts src/agents/cli-runner/prepare.test.ts src/agents/cli-runner.reliability.test.ts src/config/schema.test.ts src/config/zod-schema.agent-defaults.test.ts - GitHub CI on b63f3af: lint, prod/test types, docs, dependencies, fast contracts, core/agentic shards, and real behavior proof passed. Co-authored-by: hclsys <hclsys@openclaw.ai>
1 parent 565e71d commit aeb7d07

11 files changed

Lines changed: 247 additions & 24 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ Docs: https://docs.openclaw.ai
170170
### Fixes
171171

172172
- Memory: close temp SQLite handles before failed atomic reindex cleanup and retry Windows EBUSY/EPERM/EACCES temp file removals, so `memory index --force` does not abort or leave temp sidecars on locked filesystems. Fixes #79708. Thanks @LobsterFarmerAmp and @hclsys.
173+
- Agents/CLI: add an explicit `reseedFromRawTranscriptWhenUncompacted` backend opt-in so safe invalidated CLI sessions can reseed from a bounded raw OpenClaw transcript tail before compaction while auth-boundary resets remain no-raw. Fixes #79713. (#79764) Thanks @hclsys.
173174
- Agents/CLI: handle resumed CLI JSONL output and bound supervisor output buffering so resumed runs stay readable without letting noisy child output grow unbounded.
174175
- Codex app-server: honor per-call `timeoutMs`, configured `image_generate` timeouts, and media image-understanding timeouts for dynamic tool calls, capped at 600000 ms, so slow image generation and image analysis no longer fail at the 30s bridge default. Fixes #79810. Thanks @omarshahine.
175176
- Agents/sandbox: include the container workspace path hint in sandbox-root escape errors while preserving shortened host workspace roots. Fixes #79712. Thanks @haumanto and @hclsys.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
bb53a92a54a804d217baf466a4731924653d769db37122c38400cc3b97720c23 config-baseline.json
2-
3b632b0f038846722e2a5012a5eeec2a29048b6e385b591d7bd9122aa0981a20 config-baseline.core.json
1+
335083781741da50b280496b954794bdecba7c1150ce777d37534ccc1ec2c10a config-baseline.json
2+
b629f3b6ec6389eb0709e6f9149d7c3ab50431bb22124019541710873dc52cbb config-baseline.core.json
33
9edc62ae7dfedabc645470dd03102b813fc780b9108caf675fd661104714206f config-baseline.channel.json
44
1da42cb10427fb08510f29732493d24851ab915a424f91556569febdd450d9c3 config-baseline.plugin.json

docs/gateway/cli-backends.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ The provider id becomes the left side of your model ref:
136136
systemPromptWhen: "first",
137137
imageArg: "--image",
138138
imageMode: "repeat",
139+
// Opt in only if this backend may reseed safe invalidated sessions
140+
// from bounded raw OpenClaw transcript history before compaction.
141+
reseedFromRawTranscriptWhenUncompacted: true,
139142
serialize: true,
140143
},
141144
},
@@ -231,6 +234,13 @@ binary is not already on `PATH`.
231234
- Stored CLI sessions are provider-owned continuity. The implicit daily session
232235
reset does not cut them; `/reset` and explicit `session.reset` policies still
233236
do.
237+
- Fresh CLI sessions normally reseed only from OpenClaw's compaction summary
238+
plus post-compaction tail. To recover short sessions that are invalidated
239+
before compaction, a backend can opt in with
240+
`reseedFromRawTranscriptWhenUncompacted: true`. OpenClaw still keeps raw
241+
transcript reseed bounded and limits it to safe invalidations such as missing
242+
CLI transcripts, system-prompt/MCP changes, or session-expired retry; auth
243+
profile or credential-epoch changes never reseed raw transcript history.
234244

235245
Serialization notes:
236246

docs/gateway/config-agents.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,10 @@ Optional CLI backends for text-only fallback runs (no tool calls). Useful as a b
484484
- CLI backends are text-first; tools are always disabled.
485485
- Sessions supported when `sessionArg` is set.
486486
- Image pass-through supported when `imageArg` accepts file paths.
487+
- `reseedFromRawTranscriptWhenUncompacted: true` lets a backend recover safe
488+
invalidated sessions from a bounded raw OpenClaw transcript tail before the
489+
first compaction summary exists. Auth profile or credential-epoch changes
490+
still never raw-reseed.
487491

488492
### `agents.defaults.systemPromptOverride`
489493

src/agents/cli-runner.reliability.test.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -839,27 +839,32 @@ describe("runCliAgent reliability", () => {
839839
);
840840

841841
try {
842-
await expect(
843-
runPreparedCliAgent({
842+
const result = await runPreparedCliAgent({
843+
...buildPreparedContext({
844+
sessionKey: "agent:main:main",
845+
runId: "run-retry-success",
846+
cliSessionId: "thread-123",
847+
openClawHistoryPrompt:
848+
"Continue this conversation using the OpenClaw transcript below.\n\nUser: recovered history\n\n<next_user_message>\nhi\n</next_user_message>",
849+
}),
850+
params: {
844851
...buildPreparedContext({
845852
sessionKey: "agent:main:main",
846853
runId: "run-retry-success",
847854
cliSessionId: "thread-123",
848-
}),
849-
params: {
850-
...buildPreparedContext({
851-
sessionKey: "agent:main:main",
852-
runId: "run-retry-success",
853-
cliSessionId: "thread-123",
854-
}).params,
855-
agentId: "main",
856-
sessionFile,
857-
workspaceDir: dir,
858-
},
859-
}),
860-
).resolves.toMatchObject({
855+
openClawHistoryPrompt:
856+
"Continue this conversation using the OpenClaw transcript below.\n\nUser: recovered history\n\n<next_user_message>\nhi\n</next_user_message>",
857+
}).params,
858+
agentId: "main",
859+
sessionFile,
860+
workspaceDir: dir,
861+
},
862+
});
863+
864+
expect(result).toMatchObject({
861865
payloads: [{ text: "recovered output" }],
862866
});
867+
expect(result.meta.finalPromptText).toContain("User: recovered history");
863868

864869
await vi.waitFor(() => {
865870
expect(hookRunner.runLlmInput).toHaveBeenCalledTimes(1);

src/agents/cli-runner/prepare.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ async function createTestMcpLoopbackServer(port = 0) {
8080
}
8181

8282
function createCliBackendConfig(
83-
params: { systemPromptOverride?: string | null; bundleMcp?: boolean } = {},
83+
params: {
84+
systemPromptOverride?: string | null;
85+
bundleMcp?: boolean;
86+
reseedFromRawTranscriptWhenUncompacted?: boolean;
87+
} = {},
8488
): OpenClawConfig {
8589
return {
8690
agents: {
@@ -97,6 +101,9 @@ function createCliBackendConfig(
97101
sessionMode: "existing",
98102
output: "text",
99103
input: "arg",
104+
...(params.reseedFromRawTranscriptWhenUncompacted
105+
? { reseedFromRawTranscriptWhenUncompacted: true }
106+
: {}),
100107
...(params.bundleMcp
101108
? { bundleMcp: true, bundleMcpMode: "claude-config-file" as const }
102109
: {}),
@@ -561,6 +568,89 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
561568
}
562569
});
563570

571+
it("prepares raw-tail history for safe invalidations only when the backend opts in", async () => {
572+
const { dir, sessionFile } = createSessionFile();
573+
appendTranscriptEntry(sessionFile, {
574+
id: "msg-1",
575+
parentId: null,
576+
timestamp: new Date(1).toISOString(),
577+
message: {
578+
role: "user",
579+
content: "prior no-compaction ask",
580+
timestamp: 1,
581+
},
582+
});
583+
584+
try {
585+
const context = await prepareCliRunContext({
586+
sessionId: "session-test",
587+
sessionFile,
588+
workspaceDir: dir,
589+
prompt: "latest ask",
590+
provider: "test-cli",
591+
model: "test-model",
592+
timeoutMs: 1_000,
593+
runId: "run-test-raw-reseed-opt-in",
594+
extraSystemPrompt: "changed stable prompt",
595+
extraSystemPromptStatic: "changed stable prompt",
596+
cliSessionBinding: {
597+
sessionId: "cli-session",
598+
extraSystemPromptHash: hashCliSessionText("old stable prompt"),
599+
},
600+
config: createCliBackendConfig({
601+
systemPromptOverride: null,
602+
reseedFromRawTranscriptWhenUncompacted: true,
603+
}),
604+
});
605+
606+
expect(context.reusableCliSession).toEqual({ invalidatedReason: "system-prompt" });
607+
expect(context.openClawHistoryPrompt).toContain("prior no-compaction ask");
608+
expect(context.openClawHistoryPrompt).toContain("latest ask");
609+
} finally {
610+
fs.rmSync(dir, { recursive: true, force: true });
611+
}
612+
});
613+
614+
it("prepares opted-in raw-tail history for session-expired retry without disabling native resume", async () => {
615+
const { dir, sessionFile } = createSessionFile();
616+
appendTranscriptEntry(sessionFile, {
617+
id: "msg-1",
618+
parentId: null,
619+
timestamp: new Date(1).toISOString(),
620+
message: {
621+
role: "user",
622+
content: "prior resumable ask",
623+
timestamp: 1,
624+
},
625+
});
626+
627+
try {
628+
const context = await prepareCliRunContext({
629+
sessionId: "session-test",
630+
sessionFile,
631+
workspaceDir: dir,
632+
prompt: "latest ask",
633+
provider: "test-cli",
634+
model: "test-model",
635+
timeoutMs: 1_000,
636+
runId: "run-test-session-expired-reseed-opt-in",
637+
cliSessionBinding: {
638+
sessionId: "cli-session",
639+
},
640+
config: createCliBackendConfig({
641+
systemPromptOverride: null,
642+
reseedFromRawTranscriptWhenUncompacted: true,
643+
}),
644+
});
645+
646+
expect(context.reusableCliSession).toEqual({ sessionId: "cli-session" });
647+
expect(context.openClawHistoryPrompt).toContain("prior resumable ask");
648+
expect(context.openClawHistoryPrompt).toContain("latest ask");
649+
} finally {
650+
fs.rmSync(dir, { recursive: true, force: true });
651+
}
652+
});
653+
564654
it("applies direct-run prepend system context helpers on the CLI path", async () => {
565655
const { dir, sessionFile } = createSessionFile();
566656
try {

src/agents/cli-runner/prepare.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -406,18 +406,27 @@ export async function prepareCliRunContext(
406406
cliBackendLog.warn(`cli prompt-build hook preparation failed: ${String(error)}`);
407407
}
408408
preparedPrompt = annotateInterSessionPromptText(preparedPrompt, params.inputProvenance);
409-
const openClawHistoryPrompt = reusableCliSession.sessionId
410-
? undefined
411-
: buildCliSessionHistoryPrompt({
409+
const allowRawTranscriptReseed =
410+
backendResolved.config.reseedFromRawTranscriptWhenUncompacted === true;
411+
const rawTranscriptReseedReason = reusableCliSession.sessionId
412+
? "session-expired"
413+
: reusableCliSession.invalidatedReason;
414+
const shouldPrepareOpenClawHistoryPrompt =
415+
!reusableCliSession.sessionId || allowRawTranscriptReseed;
416+
const openClawHistoryPrompt = shouldPrepareOpenClawHistoryPrompt
417+
? buildCliSessionHistoryPrompt({
412418
messages: await loadCliSessionReseedMessages({
413419
sessionId: params.sessionId,
414420
sessionFile: params.sessionFile,
415421
sessionKey: params.sessionKey,
416422
agentId: params.agentId,
417423
config: params.config,
424+
allowRawTranscriptReseed,
425+
rawTranscriptReseedReason,
418426
}),
419427
prompt: preparedPrompt,
420-
});
428+
})
429+
: undefined;
421430
systemPrompt = appendModelIdentitySystemPrompt({
422431
systemPrompt: applyPluginTextReplacements(systemPrompt, backendResolved.textTransforms?.input),
423432
model: modelDisplay,

src/agents/cli-runner/session-history.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,76 @@ describe("loadCliSessionReseedMessages", () => {
249249
}
250250
});
251251

252+
it("reseeds safe invalidated sessions from a bounded raw message tail when explicitly opted in", async () => {
253+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
254+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
255+
const sessionFile = createSessionTranscript({
256+
rootDir: stateDir,
257+
sessionId: "session-opt-in-raw-tail",
258+
messages: Array.from(
259+
{ length: MAX_CLI_SESSION_HISTORY_MESSAGES + 25 },
260+
(_, index) => `raw-${index}`,
261+
),
262+
});
263+
264+
try {
265+
const reseed = await loadCliSessionReseedMessages({
266+
sessionId: "session-opt-in-raw-tail",
267+
sessionFile,
268+
sessionKey: "agent:main:main",
269+
agentId: "main",
270+
allowRawTranscriptReseed: true,
271+
rawTranscriptReseedReason: "missing-transcript",
272+
});
273+
expect(reseed).toHaveLength(MAX_CLI_SESSION_HISTORY_MESSAGES);
274+
expect(reseed[0]).toMatchObject({ role: "user", content: "raw-25" });
275+
expect(reseed.at(-1)).toMatchObject({
276+
role: "user",
277+
content: `raw-${MAX_CLI_SESSION_HISTORY_MESSAGES + 24}`,
278+
});
279+
expect(buildCliSessionHistoryPrompt({ messages: reseed, prompt: "next" })).toContain(
280+
"raw-25",
281+
);
282+
} finally {
283+
fs.rmSync(stateDir, { recursive: true, force: true });
284+
}
285+
});
286+
287+
it("does not raw-reseed auth-boundary invalidations even when opted in", async () => {
288+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
289+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
290+
const sessionFile = createSessionTranscript({
291+
rootDir: stateDir,
292+
sessionId: "session-auth-boundary",
293+
messages: ["previous account context"],
294+
});
295+
296+
try {
297+
await expect(
298+
loadCliSessionReseedMessages({
299+
sessionId: "session-auth-boundary",
300+
sessionFile,
301+
sessionKey: "agent:main:main",
302+
agentId: "main",
303+
allowRawTranscriptReseed: true,
304+
rawTranscriptReseedReason: "auth-profile",
305+
}),
306+
).resolves.toStrictEqual([]);
307+
await expect(
308+
loadCliSessionReseedMessages({
309+
sessionId: "session-auth-boundary",
310+
sessionFile,
311+
sessionKey: "agent:main:main",
312+
agentId: "main",
313+
allowRawTranscriptReseed: true,
314+
rawTranscriptReseedReason: "auth-epoch",
315+
}),
316+
).resolves.toStrictEqual([]);
317+
} finally {
318+
fs.rmSync(stateDir, { recursive: true, force: true });
319+
}
320+
});
321+
252322
it("reseeds fresh CLI sessions from the latest compaction summary and post-compaction tail", async () => {
253323
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
254324
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);

src/agents/cli-runner/session-history.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ type HistoryEntry = {
2828
summary?: unknown;
2929
};
3030

31+
type RawTranscriptReseedReason =
32+
| "auth-profile"
33+
| "auth-epoch"
34+
| "system-prompt"
35+
| "mcp"
36+
| "missing-transcript"
37+
| "session-expired";
38+
39+
const RAW_TRANSCRIPT_RESEED_ALLOWED_REASONS = new Set<RawTranscriptReseedReason>([
40+
"missing-transcript",
41+
"system-prompt",
42+
"mcp",
43+
"session-expired",
44+
]);
45+
3146
function coerceHistoryText(content: unknown): string {
3247
if (typeof content === "string") {
3348
return content.trim();
@@ -190,20 +205,36 @@ export async function loadCliSessionReseedMessages(params: {
190205
sessionKey?: string;
191206
agentId?: string;
192207
config?: OpenClawConfig;
208+
allowRawTranscriptReseed?: boolean;
209+
rawTranscriptReseedReason?: RawTranscriptReseedReason;
193210
}): Promise<unknown[]> {
194211
const entries = await loadCliSessionEntries(params);
212+
const loadRawTail = () => {
213+
if (
214+
params.allowRawTranscriptReseed !== true ||
215+
!params.rawTranscriptReseedReason ||
216+
!RAW_TRANSCRIPT_RESEED_ALLOWED_REASONS.has(params.rawTranscriptReseedReason)
217+
) {
218+
return [];
219+
}
220+
const rawTail = entries.flatMap((entry) => {
221+
const candidate = entry as HistoryEntry;
222+
return candidate.type === "message" ? [candidate.message] : [];
223+
});
224+
return limitAgentHookHistoryMessages(rawTail, MAX_CLI_SESSION_HISTORY_MESSAGES);
225+
};
195226
const latestCompactionIndex = entries.findLastIndex((entry) => {
196227
const candidate = entry as HistoryEntry;
197228
return candidate.type === "compaction" && typeof candidate.summary === "string";
198229
});
199230
if (latestCompactionIndex < 0) {
200-
return [];
231+
return loadRawTail();
201232
}
202233

203234
const compaction = entries[latestCompactionIndex] as HistoryEntry;
204235
const summary = typeof compaction.summary === "string" ? compaction.summary.trim() : "";
205236
if (!summary) {
206-
return [];
237+
return loadRawTail();
207238
}
208239

209240
const tailMessages = entries.slice(latestCompactionIndex + 1).flatMap((entry) => {

src/config/types.agent-defaults.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ export type CliBackendConfig = {
150150
imagePathScope?: "temp" | "workspace";
151151
/** Serialize runs for this CLI. */
152152
serialize?: boolean;
153+
/** Opt in to bounded raw transcript reseed before compaction for safe session resets. */
154+
reseedFromRawTranscriptWhenUncompacted?: boolean;
153155
/** Runtime reliability tuning for this backend's process lifecycle. */
154156
reliability?: {
155157
/** Live-session output caps for CLIs that stream JSONL through a long-lived process. */

0 commit comments

Comments
 (0)