Skip to content

Commit 3a6de1b

Browse files
fix(agents/cli-runner): reseed claude-cli context after invalidator-driven session reset
When `prepareCliRunContext` invalidates a claude-cli session for ANY reason — missing-transcript, orphaned-tool-use, system-prompt, mcp, auth-profile, auth-epoch — the runtime starts a fresh claude-cli session and the agent loses memory of every prior turn. The OC-side history reseed (`buildCliSessionHistoryPrompt` / `loadCliSessionReseedMessages`) only fires on a pre-existing compaction summary, which agents that haven't been long enough to compact don't have. The user-visible result: the silent-abort cycle is replaced by amnesia. Same end-user pain, different shape. The dead claude-cli transcript still has the full conversation on disk (it's what we just walked to find the orphan, or what the missing-transcript / system-prompt / mcp paths were just told to discard). Read it, build a `priorContextPrelude` from the main- conversation messages (sidechain already filtered out by the existing `parseClaudeCliHistoryEntry`), and prepend via the same `resolveFallbackRetryPrompt` shape the fallback-to-different-provider path already uses. The fresh session inherits the conversation; the recovery becomes invisible to the user beyond a one-turn delay. Note: `session-expired` retains the sessionId, so it doesn't apply here — it's already covered by the existing `rawTranscriptReseedReason` path that runs `buildCliSessionHistoryPrompt`. Surfaces `buildClaudeCliFallbackContextPrelude` through `prepareDeps` so tests can stub the on-disk reader without seeding a real `~/.claude/projects/<encoded-cwd>/<sid>.jsonl`. Tests cover: - reseed fires for `missing-transcript` invalidation - reseed fires for `orphaned-tool-use` invalidation - reseed fires for `system-prompt` invalidation - reseed does NOT fire when the session is reusable - reseed does NOT fire for non-claude-cli providers Note: this only mitigates the user-visible amnesia. The upstream cause of the orphan creation (live-session manager tearing down claude-cli mid-tool on fingerprint mismatch) and the system-prompt-drift cause (extraSystemPromptHash changing across turns despite the static-only hashing intent) are still tracked separately — see the diagnostic in `claude-live-session.ts` from the prior commit on this branch. AI-assisted: yes. Tooling: Claude Opus + claude-cli.
1 parent 8b8e825 commit 3a6de1b

2 files changed

Lines changed: 318 additions & 0 deletions

File tree

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

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent";
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import type { OpenClawConfig } from "../../config/types.openclaw.js";
77
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
8+
import { CLI_AUTH_EPOCH_VERSION } from "../cli-auth-epoch.js";
89
import { __testing as cliBackendsTesting } from "../cli-backends.js";
910
import { hashCliSessionText } from "../cli-session.js";
1011
import { buildActiveMusicGenerationTaskPromptContextForSession } from "../music-generation-task-status.js";
@@ -993,4 +994,279 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
993994
fs.rmSync(dir, { recursive: true, force: true });
994995
}
995996
});
997+
998+
it("reseeds prior context from the dead claude-cli transcript when invalidator fires for missing-transcript", async () => {
999+
const { dir, sessionFile } = createSessionFile();
1000+
try {
1001+
cliBackendsTesting.setDepsForTest({
1002+
resolvePluginSetupCliBackend: () => undefined,
1003+
resolveRuntimeCliBackends: () => [
1004+
{
1005+
id: "claude-cli",
1006+
pluginId: "anthropic",
1007+
bundleMcp: false,
1008+
config: {
1009+
command: "claude",
1010+
args: ["--print"],
1011+
resumeArgs: ["--resume", "{sessionId}"],
1012+
output: "jsonl",
1013+
input: "stdin",
1014+
sessionMode: "existing",
1015+
},
1016+
},
1017+
],
1018+
});
1019+
const transcriptCheck = vi.fn(async () => false);
1020+
const orphanCheck = vi.fn(async () => false);
1021+
const fallbackPrelude = vi.fn(
1022+
() =>
1023+
"## Prior session context (from claude-cli)\n\nuser: prior question\nassistant: prior answer",
1024+
);
1025+
setCliRunnerPrepareTestDeps({
1026+
claudeCliSessionTranscriptHasContent: transcriptCheck,
1027+
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
1028+
buildClaudeCliFallbackContextPrelude: fallbackPrelude,
1029+
});
1030+
1031+
const context = await prepareCliRunContext({
1032+
sessionId: "session-test",
1033+
sessionKey: "agent:main:telegram:direct:peer",
1034+
sessionFile,
1035+
workspaceDir: dir,
1036+
prompt: "follow-up ask",
1037+
provider: "claude-cli",
1038+
model: "opus",
1039+
timeoutMs: 1_000,
1040+
runId: "run-reseed-missing",
1041+
cliSessionBinding: { sessionId: "stale-claude-sid" },
1042+
cliSessionId: "stale-claude-sid",
1043+
config: createCliBackendConfig({ systemPromptOverride: null }),
1044+
});
1045+
1046+
expect(context.reusableCliSession).toEqual({ invalidatedReason: "missing-transcript" });
1047+
expect(fallbackPrelude).toHaveBeenCalledWith({ cliSessionId: "stale-claude-sid" });
1048+
expect(context.params.prompt).toContain("Prior session context (from claude-cli)");
1049+
expect(context.params.prompt).toContain(
1050+
"[Retry after the previous model attempt failed or timed out]",
1051+
);
1052+
expect(context.params.prompt).toContain("follow-up ask");
1053+
} finally {
1054+
fs.rmSync(dir, { recursive: true, force: true });
1055+
}
1056+
});
1057+
1058+
it("reseeds prior context when invalidator fires for orphaned-tool-use", async () => {
1059+
const { dir, sessionFile } = createSessionFile();
1060+
try {
1061+
cliBackendsTesting.setDepsForTest({
1062+
resolvePluginSetupCliBackend: () => undefined,
1063+
resolveRuntimeCliBackends: () => [
1064+
{
1065+
id: "claude-cli",
1066+
pluginId: "anthropic",
1067+
bundleMcp: false,
1068+
config: {
1069+
command: "claude",
1070+
args: ["--print"],
1071+
resumeArgs: ["--resume", "{sessionId}"],
1072+
output: "jsonl",
1073+
input: "stdin",
1074+
sessionMode: "existing",
1075+
},
1076+
},
1077+
],
1078+
});
1079+
const transcriptCheck = vi.fn(async () => true);
1080+
const orphanCheck = vi.fn(async () => true);
1081+
const fallbackPrelude = vi.fn(
1082+
() => "## Prior session context (from claude-cli)\n\nassistant: stuck mid-tool reply",
1083+
);
1084+
setCliRunnerPrepareTestDeps({
1085+
claudeCliSessionTranscriptHasContent: transcriptCheck,
1086+
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
1087+
buildClaudeCliFallbackContextPrelude: fallbackPrelude,
1088+
});
1089+
1090+
const context = await prepareCliRunContext({
1091+
sessionId: "session-test",
1092+
sessionKey: "agent:main:telegram:direct:peer",
1093+
sessionFile,
1094+
workspaceDir: dir,
1095+
prompt: "are you there?",
1096+
provider: "claude-cli",
1097+
model: "opus",
1098+
timeoutMs: 1_000,
1099+
runId: "run-reseed-orphan",
1100+
cliSessionBinding: { sessionId: "orphaned-claude-sid" },
1101+
cliSessionId: "orphaned-claude-sid",
1102+
config: createCliBackendConfig({ systemPromptOverride: null }),
1103+
});
1104+
1105+
expect(context.reusableCliSession).toEqual({ invalidatedReason: "orphaned-tool-use" });
1106+
expect(fallbackPrelude).toHaveBeenCalledWith({ cliSessionId: "orphaned-claude-sid" });
1107+
expect(context.params.prompt).toContain("Prior session context (from claude-cli)");
1108+
expect(context.params.prompt).toContain("are you there?");
1109+
} finally {
1110+
fs.rmSync(dir, { recursive: true, force: true });
1111+
}
1112+
});
1113+
1114+
it("does not call the prelude builder when the claude-cli session is reusable (no invalidation)", async () => {
1115+
const { dir, sessionFile } = createSessionFile();
1116+
try {
1117+
cliBackendsTesting.setDepsForTest({
1118+
resolvePluginSetupCliBackend: () => undefined,
1119+
resolveRuntimeCliBackends: () => [
1120+
{
1121+
id: "claude-cli",
1122+
pluginId: "anthropic",
1123+
bundleMcp: false,
1124+
config: {
1125+
command: "claude",
1126+
args: ["--print"],
1127+
resumeArgs: ["--resume", "{sessionId}"],
1128+
output: "jsonl",
1129+
input: "stdin",
1130+
sessionMode: "existing",
1131+
},
1132+
},
1133+
],
1134+
});
1135+
const transcriptCheck = vi.fn(async () => true);
1136+
const orphanCheck = vi.fn(async () => false);
1137+
const fallbackPrelude = vi.fn(() => "");
1138+
setCliRunnerPrepareTestDeps({
1139+
claudeCliSessionTranscriptHasContent: transcriptCheck,
1140+
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
1141+
buildClaudeCliFallbackContextPrelude: fallbackPrelude,
1142+
});
1143+
1144+
const context = await prepareCliRunContext({
1145+
sessionId: "session-test",
1146+
sessionKey: "agent:main:telegram:direct:peer",
1147+
sessionFile,
1148+
workspaceDir: dir,
1149+
prompt: "next ask",
1150+
provider: "claude-cli",
1151+
model: "opus",
1152+
timeoutMs: 1_000,
1153+
runId: "run-reseed-skip",
1154+
cliSessionBinding: { sessionId: "live-claude-sid" },
1155+
cliSessionId: "live-claude-sid",
1156+
config: createCliBackendConfig({ systemPromptOverride: null }),
1157+
});
1158+
1159+
expect(context.reusableCliSession).toEqual({ sessionId: "live-claude-sid" });
1160+
expect(fallbackPrelude).not.toHaveBeenCalled();
1161+
expect(context.params.prompt).not.toContain("Prior session context");
1162+
} finally {
1163+
fs.rmSync(dir, { recursive: true, force: true });
1164+
}
1165+
});
1166+
1167+
it("reseeds prior context when invalidator fires for system-prompt change", async () => {
1168+
// system-prompt invalidation happens when the cli session reuse layer
1169+
// detects that the agent's static system prompt hashed differently
1170+
// than the binding's stored hash. Same end-user pain as the other
1171+
// invalidation paths (fresh CLI session, no memory), so the same
1172+
// recovery applies — read the dead transcript and reseed.
1173+
const { dir, sessionFile } = createSessionFile();
1174+
try {
1175+
cliBackendsTesting.setDepsForTest({
1176+
resolvePluginSetupCliBackend: () => undefined,
1177+
resolveRuntimeCliBackends: () => [
1178+
{
1179+
id: "claude-cli",
1180+
pluginId: "anthropic",
1181+
bundleMcp: false,
1182+
config: {
1183+
command: "claude",
1184+
args: ["--print"],
1185+
resumeArgs: ["--resume", "{sessionId}"],
1186+
output: "jsonl",
1187+
input: "stdin",
1188+
sessionMode: "existing",
1189+
},
1190+
},
1191+
],
1192+
});
1193+
const transcriptCheck = vi.fn(async () => true);
1194+
const orphanCheck = vi.fn(async () => false);
1195+
const fallbackPrelude = vi.fn(
1196+
() =>
1197+
"## Prior session context (from claude-cli)\n\nuser: prior question\nassistant: prior answer",
1198+
);
1199+
setCliRunnerPrepareTestDeps({
1200+
claudeCliSessionTranscriptHasContent: transcriptCheck,
1201+
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
1202+
buildClaudeCliFallbackContextPrelude: fallbackPrelude,
1203+
});
1204+
1205+
// Drive a system-prompt invalidation by passing a binding whose
1206+
// extraSystemPromptHash doesn't match the current hash. The runtime
1207+
// recomputes the hash from extraSystemPromptStatic/extraSystemPrompt;
1208+
// we set the binding hash to a fixed wrong value.
1209+
const context = await prepareCliRunContext({
1210+
sessionId: "session-test",
1211+
sessionKey: "agent:main:telegram:direct:peer",
1212+
sessionFile,
1213+
workspaceDir: dir,
1214+
prompt: "follow-up",
1215+
provider: "claude-cli",
1216+
model: "opus",
1217+
timeoutMs: 1_000,
1218+
runId: "run-reseed-system-prompt",
1219+
extraSystemPrompt: "agent-static-prompt",
1220+
extraSystemPromptStatic: "agent-static-prompt",
1221+
cliSessionBinding: {
1222+
sessionId: "stale-system-prompt-sid",
1223+
extraSystemPromptHash: "deadbeefdeadbeef",
1224+
authEpochVersion: CLI_AUTH_EPOCH_VERSION,
1225+
},
1226+
cliSessionId: "stale-system-prompt-sid",
1227+
config: createCliBackendConfig({ systemPromptOverride: null }),
1228+
});
1229+
1230+
expect(context.reusableCliSession).toEqual({ invalidatedReason: "system-prompt" });
1231+
expect(fallbackPrelude).toHaveBeenCalledWith({ cliSessionId: "stale-system-prompt-sid" });
1232+
expect(context.params.prompt).toContain("Prior session context (from claude-cli)");
1233+
expect(context.params.prompt).toContain("follow-up");
1234+
} finally {
1235+
fs.rmSync(dir, { recursive: true, force: true });
1236+
}
1237+
});
1238+
1239+
it("does not reseed for non-claude-cli providers even on invalidation-shaped paths", async () => {
1240+
const { dir, sessionFile } = createSessionFile();
1241+
try {
1242+
const transcriptCheck = vi.fn(async () => false);
1243+
const orphanCheck = vi.fn(async () => false);
1244+
const fallbackPrelude = vi.fn(() => "should-not-be-called");
1245+
setCliRunnerPrepareTestDeps({
1246+
claudeCliSessionTranscriptHasContent: transcriptCheck,
1247+
claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck,
1248+
buildClaudeCliFallbackContextPrelude: fallbackPrelude,
1249+
});
1250+
1251+
const context = await prepareCliRunContext({
1252+
sessionId: "session-test",
1253+
sessionFile,
1254+
workspaceDir: dir,
1255+
prompt: "next ask",
1256+
provider: "test-cli",
1257+
model: "test-model",
1258+
timeoutMs: 1_000,
1259+
runId: "run-reseed-other-provider",
1260+
cliSessionBinding: { sessionId: "test-cli-sid" },
1261+
config: createCliBackendConfig({ systemPromptOverride: null }),
1262+
});
1263+
1264+
expect(transcriptCheck).not.toHaveBeenCalled();
1265+
expect(orphanCheck).not.toHaveBeenCalled();
1266+
expect(fallbackPrelude).not.toHaveBeenCalled();
1267+
expect(context.params.prompt).not.toContain("should-not-be-called");
1268+
} finally {
1269+
fs.rmSync(dir, { recursive: true, force: true });
1270+
}
1271+
});
9961272
});

src/agents/cli-runner/prepare.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ import { CLI_AUTH_EPOCH_VERSION, resolveCliAuthEpoch } from "../cli-auth-epoch.j
3030
import { resolveCliBackendConfig } from "../cli-backends.js";
3131
import { hashCliSessionText, resolveCliSessionReuse } from "../cli-session.js";
3232
import {
33+
buildClaudeCliFallbackContextPrelude,
3334
claudeCliSessionTranscriptHasContent,
3435
claudeCliSessionTranscriptHasOrphanedToolUse,
36+
resolveFallbackRetryPrompt,
3537
} from "../command/attempt-execution.helpers.js";
3638
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
3739
import {
@@ -72,6 +74,9 @@ const prepareDeps = {
7274
// without touching ~/.claude/projects.
7375
claudeCliSessionTranscriptHasContent,
7476
claudeCliSessionTranscriptHasOrphanedToolUse,
77+
// Surfaced so tests can stub the claude-cli transcript reader without
78+
// having to seed a real `~/.claude/projects/<encoded-cwd>/<sid>.jsonl`.
79+
buildClaudeCliFallbackContextPrelude,
7580
};
7681

7782
export function setCliRunnerPrepareTestDeps(overrides: Partial<typeof prepareDeps>): void {
@@ -433,6 +438,43 @@ export async function prepareCliRunContext(
433438
prompt: preparedPrompt,
434439
});
435440
preparedPrompt = annotateInterSessionPromptText(preparedPrompt, params.inputProvenance);
441+
442+
// When we just invalidated a claude-cli session for ANY reason, the OC-side
443+
// session-history reseed only fires on a pre-existing compaction summary —
444+
// which agents that haven't been long enough to compact don't have. The
445+
// user-visible result was amnesia: the recovered session has no memory of
446+
// the invalidated one. Read the dead claude-cli transcript directly (it's
447+
// still on disk and has the full conversation) and prepend a
448+
// `priorContextPrelude` so the fresh session inherits the conversation
449+
// context. This reuses the same builder the failover-to-different-provider
450+
// path uses (`buildClaudeCliFallbackContextPrelude`), since the shape of
451+
// the problem is identical: "fresh CLI session, please continue this
452+
// prior conversation."
453+
//
454+
// We fire for EVERY invalidation reason — missing-transcript,
455+
// orphaned-tool-use, system-prompt, mcp, auth-profile, auth-epoch — because
456+
// every one of them produces a fresh CLI session with no prior context.
457+
// (`session-expired` retains the sessionId, so it doesn't apply here; it's
458+
// already covered by the existing `rawTranscriptReseedReason` path below.)
459+
const recoverFromClaudeCliInvalidation =
460+
!reusableCliSession.sessionId &&
461+
reusableCliSession.invalidatedReason !== undefined &&
462+
isClaudeCliProvider(params.provider) &&
463+
candidateClaudeCliSessionId !== undefined;
464+
if (recoverFromClaudeCliInvalidation) {
465+
const recoveryPrelude = prepareDeps.buildClaudeCliFallbackContextPrelude({
466+
cliSessionId: candidateClaudeCliSessionId,
467+
});
468+
if (recoveryPrelude) {
469+
preparedPrompt = resolveFallbackRetryPrompt({
470+
body: preparedPrompt,
471+
isFallbackRetry: true,
472+
sessionHasHistory: true,
473+
priorContextPrelude: recoveryPrelude,
474+
});
475+
}
476+
}
477+
436478
const allowRawTranscriptReseed =
437479
backendResolved.config.reseedFromRawTranscriptWhenUncompacted === true;
438480
const rawTranscriptReseedReason = reusableCliSession.sessionId

0 commit comments

Comments
 (0)