From a96f1fa5ef66e619571ee7a8d0a00a3f1fe06553 Mon Sep 17 00:00:00 2001 From: stainlu Date: Sun, 26 Apr 2026 16:45:03 +0800 Subject: [PATCH] fix(agents): seed claude-cli fallback prompts with prior-session context (#69973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a claude-cli attempt failed with a fallbackable error (e.g. a 402 billing limit), the next candidate -- typically a non-CLI provider -- ran with no prior conversation context. Claude Code keeps its own JSONL session under ~/.claude/projects/, but the fallback runner only sees what OpenClaw assembles from its own transcript, which is empty for claude-cli sessions. The fallback model therefore behaved as if the conversation just started, even though Claude later resumed fine. Resolution mirrors what Claude Code itself does on resume after compaction: prefer the explicit `/compact` summary, then append the most recent post-boundary turns up to a char budget. Concretely: - `readClaudeCliFallbackSeed` (gateway): walks the Claude JSONL with awareness of `type: "summary"` and `type: "system", subtype: "compact_boundary"` entries. Pre-boundary turns are dropped (they are represented by the summary); post-boundary turns become the recent-window. Multiple compactions are handled by preferring the latest summary. Path safety reuses the existing `resolveClaudeCliSessionFilePath` validation. - `formatClaudeCliFallbackPrelude` / `buildClaudeCliFallbackContext\ Prelude` (agents helpers): format the harvested seed into a labeled prelude. Tool blocks are coalesced to compact "(tool call: name)" / "(tool result: …)" hints to keep the prompt budget honest. Newest turns are kept first when truncating; the summary is clearly labeled "(truncated)" if it overflows. - `resolveFallbackRetryPrompt`: gains an optional `priorContextPrelude` that prepends before the existing retry marker. Empty/whitespace preludes are ignored; first-attempt prompts are unchanged. - `runAgentAttempt`: builds the prelude when `isFallbackRetry === true` AND the new candidate is non-claude-cli AND a Claude-cli session binding is present. Same-provider fallbacks (claude-cli to claude-cli) are unaffected because Claude's own --resume still works. Verified the new tests (12 in cli-session-history, 12 added to attempt-execution) catch the regression: removing the prelude prepend in resolveFallbackRetryPrompt makes both new prelude cases fail, restoring the original cold-start behavior. References: - https://code.claude.com/docs/en/how-claude-code-works - "Inside Claude Code: The Session File Format" https://databunny.medium.com/inside-claude-code-the-session-file-format-and-how-to-inspect-it-b9998e66d56b --- .../command/attempt-execution.helpers.ts | 176 +++++++++++++- src/agents/command/attempt-execution.test.ts | 189 +++++++++++++++ src/agents/command/attempt-execution.ts | 15 ++ src/gateway/cli-session-history.claude.ts | 144 ++++++++++++ src/gateway/cli-session-history.test.ts | 217 +++++++++++++++++- src/gateway/cli-session-history.ts | 5 + 6 files changed, 743 insertions(+), 3 deletions(-) diff --git a/src/agents/command/attempt-execution.helpers.ts b/src/agents/command/attempt-execution.helpers.ts index ff9ecbbe0ea..fc93574f92b 100644 --- a/src/agents/command/attempt-execution.helpers.ts +++ b/src/agents/command/attempt-execution.helpers.ts @@ -9,6 +9,10 @@ import { startsWithSilentToken, stripLeadingSilentToken, } from "../../auto-reply/tokens.js"; +import { + type ClaudeCliFallbackSeed, + readClaudeCliFallbackSeed, +} from "../../gateway/cli-session-history.js"; /** Maximum number of JSONL records to inspect before giving up. */ const SESSION_FILE_MAX_RECORDS = 500; @@ -105,11 +109,21 @@ export function resolveFallbackRetryPrompt(params: { body: string; isFallbackRetry: boolean; sessionHasHistory?: boolean; + /** + * Optional context prelude (e.g., a compacted summary harvested from a + * non-OpenClaw transcript such as Claude Code's local JSONL). Prepended + * before the retry marker so the fallback candidate has prior context + * even when OpenClaw's own session file is empty for the current + * provider — see `buildClaudeCliFallbackContextPrelude` for the + * claude-cli case (#69973). + */ + priorContextPrelude?: string; }): string { if (!params.isFallbackRetry) { return params.body; } - if (!params.sessionHasHistory) { + const prelude = params.priorContextPrelude?.trim(); + if (!params.sessionHasHistory && !prelude) { return params.body; } // Even with persisted session history, fully replacing the body with a @@ -118,7 +132,165 @@ export function resolveFallbackRetryPrompt(params: { // instruction from history alone, which is fragile and sometimes // impossible. Prepend the retry context to the original body instead so // the fallback model has both the recovery signal AND the task. (#65760) - return `[Retry after the previous model attempt failed or timed out]\n\n${params.body}`; + const retryMarked = `[Retry after the previous model attempt failed or timed out]\n\n${params.body}`; + return prelude ? `${prelude}\n\n${retryMarked}` : retryMarked; +} + +const CLAUDE_CLI_FALLBACK_PRELUDE_DEFAULT_CHAR_BUDGET = 8_000; +const CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS = 64; + +type FallbackTurnLikeMessage = Record; + +function extractFallbackTurnText(message: FallbackTurnLikeMessage): string { + const content = message.content; + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + const parts: string[] = []; + for (const block of content) { + if (typeof block === "string") { + parts.push(block); + continue; + } + if (!block || typeof block !== "object") { + continue; + } + const rec = block as Record; + if (typeof rec.text === "string") { + parts.push(rec.text); + continue; + } + // Tool calls: render as a compact "(tool: name)" hint so the fallback + // model sees the conversation flow without the full tool argument blob, + // which is rarely useful out of context and chews through char budget. + if (rec.type === "tool_use" && typeof rec.name === "string") { + parts.push(`(tool call: ${rec.name})`); + continue; + } + if (rec.type === "tool_result") { + const inner = typeof rec.content === "string" ? rec.content : undefined; + if (inner) { + parts.push(`(tool result: ${inner})`); + } else { + parts.push("(tool result)"); + } + } + } + return parts.join("\n").trim(); +} + +function formatFallbackTurns( + turns: ReadonlyArray, + remainingBudget: number, +): { text: string; consumed: number } { + if (turns.length === 0 || remainingBudget <= 0) { + return { text: "", consumed: 0 }; + } + // Walk newest -> oldest, prepending lines until we exceed the budget. + // Stops at the oldest turn we can include in full so we never deliver a + // truncated mid-turn fragment to the fallback model. + const lines: string[] = []; + let consumed = 0; + for (let i = turns.length - 1; i >= 0; i -= 1) { + const turn = turns[i]; + if (!turn || typeof turn !== "object") { + continue; + } + const role = turn.role; + if (role !== "user" && role !== "assistant") { + continue; + } + const text = extractFallbackTurnText(turn); + if (!text) { + continue; + } + const line = `${role}: ${text}`; + if (consumed + line.length + 1 > remainingBudget) { + // Skip this turn rather than chop it; if even the most recent turn + // is too large to include cleanly, stop emitting (the prelude is a + // best-effort sketch, not a transcript). + break; + } + lines.unshift(line); + consumed += line.length + 1; + } + return { text: lines.join("\n"), consumed }; +} + +/** + * Format a previously-harvested Claude CLI session into a labeled prelude + * suitable for prepending to a fallback candidate's prompt. Behavior matches + * Claude Code's own resume strategy after compaction: prefer the explicit + * summary, then append the most recent turns up to a char budget. + * + * Returns an empty string when neither a summary nor any usable turn fits in + * the budget; callers can treat that as "no context to seed". + */ +export function formatClaudeCliFallbackPrelude( + seed: ClaudeCliFallbackSeed, + options?: { charBudget?: number }, +): string { + const charBudget = Math.max( + CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS, + options?.charBudget ?? CLAUDE_CLI_FALLBACK_PRELUDE_DEFAULT_CHAR_BUDGET, + ); + const sections: string[] = ["## Prior session context (from claude-cli)"]; + let remaining = charBudget - sections[0]!.length; + if (seed.summaryText) { + const summarySection = `\nSummary of earlier conversation:\n${seed.summaryText}`; + if (summarySection.length <= remaining) { + sections.push(summarySection); + remaining -= summarySection.length; + } else { + // Truncate the summary at a word boundary if it's huge; clearly mark + // the truncation so the fallback model treats the prelude as a hint, + // not exhaustive state. + const slice = seed.summaryText.slice(0, Math.max(0, remaining - 64)); + const lastBreak = slice.lastIndexOf(" "); + const trimmed = lastBreak > 0 ? slice.slice(0, lastBreak).trimEnd() : slice.trimEnd(); + sections.push(`\nSummary of earlier conversation (truncated):\n${trimmed} …`); + remaining = 0; + } + } + if (remaining > CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS && seed.recentTurns.length > 0) { + const { text } = formatFallbackTurns( + seed.recentTurns as ReadonlyArray, + remaining - 32, + ); + if (text) { + sections.push(`\nRecent turns:\n${text}`); + } + } + // No summary AND no fittable turns => nothing to seed beyond the heading, + // which would just confuse the model. Drop the prelude entirely. + if (sections.length === 1) { + return ""; + } + return sections.join("\n"); +} + +/** + * Read the Claude CLI session pointed to by `cliSessionId` and format a + * fallback prelude. Returns `""` when no session file is found or when the + * harvested seed has no usable content. + */ +export function buildClaudeCliFallbackContextPrelude(params: { + cliSessionId: string | undefined; + homeDir?: string; + charBudget?: number; +}): string { + const sessionId = params.cliSessionId?.trim(); + if (!sessionId) { + return ""; + } + const seed = readClaudeCliFallbackSeed({ cliSessionId: sessionId, homeDir: params.homeDir }); + if (!seed) { + return ""; + } + return formatClaudeCliFallbackPrelude(seed, { charBudget: params.charBudget }); } export function createAcpVisibleTextAccumulator() { diff --git a/src/agents/command/attempt-execution.test.ts b/src/agents/command/attempt-execution.test.ts index 97f21822d4a..b599449f606 100644 --- a/src/agents/command/attempt-execution.test.ts +++ b/src/agents/command/attempt-execution.test.ts @@ -3,8 +3,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + buildClaudeCliFallbackContextPrelude, claudeCliSessionTranscriptHasContent, createAcpVisibleTextAccumulator, + formatClaudeCliFallbackPrelude, resolveFallbackRetryPrompt, sessionFileHasContent, } from "./attempt-execution.helpers.js"; @@ -77,6 +79,193 @@ describe("resolveFallbackRetryPrompt", () => { }), ).toBe(originalBody); }); + + // #69973: even when OpenClaw's own session file is empty (the claude-cli + // case where Claude Code maintains its own JSONL), a harvested + // priorContextPrelude must still seed the retry prompt so the fallback + // candidate has prior context. + it("prepends priorContextPrelude before the retry marker on fallback retry", () => { + const prelude = "## Prior session context (from claude-cli)\nuser: prior question"; + const result = resolveFallbackRetryPrompt({ + body: originalBody, + isFallbackRetry: true, + sessionHasHistory: true, + priorContextPrelude: prelude, + }); + expect(result).toBe( + `${prelude}\n\n[Retry after the previous model attempt failed or timed out]\n\n${originalBody}`, + ); + }); + + it("emits the retry prompt with prelude even when sessionHasHistory is false (claude-cli case)", () => { + const prelude = "## Prior session context (from claude-cli)\nuser: prior question"; + const result = resolveFallbackRetryPrompt({ + body: originalBody, + isFallbackRetry: true, + sessionHasHistory: false, + priorContextPrelude: prelude, + }); + expect(result).toBe( + `${prelude}\n\n[Retry after the previous model attempt failed or timed out]\n\n${originalBody}`, + ); + }); + + it("ignores empty/whitespace priorContextPrelude", () => { + expect( + resolveFallbackRetryPrompt({ + body: originalBody, + isFallbackRetry: true, + sessionHasHistory: false, + priorContextPrelude: " \n ", + }), + ).toBe(originalBody); + }); + + it("does not prepend prelude on non-fallback first attempts", () => { + expect( + resolveFallbackRetryPrompt({ + body: originalBody, + isFallbackRetry: false, + sessionHasHistory: true, + priorContextPrelude: "anything", + }), + ).toBe(originalBody); + }); +}); + +describe("formatClaudeCliFallbackPrelude", () => { + it("returns empty string when seed has neither summary nor turns", () => { + expect(formatClaudeCliFallbackPrelude({ recentTurns: [] })).toBe(""); + }); + + it("emits summary alone when no turns are available", () => { + const out = formatClaudeCliFallbackPrelude({ + summaryText: "User wants to ship a billing-aware fallback.", + recentTurns: [], + }); + expect(out).toContain("## Prior session context (from claude-cli)"); + expect(out).toContain("Summary of earlier conversation:"); + expect(out).toContain("User wants to ship a billing-aware fallback."); + expect(out).not.toContain("Recent turns:"); + }); + + it("formats user/assistant turns and tags tool blocks with compact hints", () => { + const out = formatClaudeCliFallbackPrelude({ + recentTurns: [ + { + role: "user", + content: "Earlier user question", + }, + { + role: "assistant", + content: [ + { type: "text", text: "Earlier assistant reply" }, + { type: "tool_use", name: "Bash" }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_x", + content: "Earlier tool output", + }, + ], + }, + ], + }); + expect(out).toContain("## Prior session context (from claude-cli)"); + expect(out).toContain("Recent turns:"); + expect(out).toContain("user: Earlier user question"); + expect(out).toContain("assistant: Earlier assistant reply"); + expect(out).toContain("(tool call: Bash)"); + expect(out).toContain("(tool result: Earlier tool output)"); + }); + + it("truncates an oversized summary instead of dropping it silently", () => { + const huge = "x ".repeat(10_000).trim(); + const out = formatClaudeCliFallbackPrelude( + { summaryText: huge, recentTurns: [] }, + { charBudget: 600 }, + ); + expect(out).toContain("Summary of earlier conversation (truncated):"); + expect(out.length).toBeLessThan(800); + // Trailing ellipsis tells the model the summary was clipped — better + // than silently emitting a fragment that looks complete. + expect(out).toMatch(/…$/); + }); + + it("drops oldest turns first when the budget cannot fit all of them", () => { + const turns = Array.from({ length: 10 }, (_, i) => ({ + role: "user" as const, + content: `turn ${i + 1} ${"x".repeat(80)}`, + })); + const out = formatClaudeCliFallbackPrelude({ recentTurns: turns }, { charBudget: 350 }); + // Newest turn (turn 10) must be present; oldest (turn 1) must not be. + expect(out).toContain("turn 10"); + expect(out).not.toContain("turn 1 "); + }); +}); + +describe("buildClaudeCliFallbackContextPrelude", () => { + it("returns empty string when no sessionId is provided", () => { + expect(buildClaudeCliFallbackContextPrelude({ cliSessionId: undefined })).toBe(""); + expect(buildClaudeCliFallbackContextPrelude({ cliSessionId: " " })).toBe(""); + }); + + it("returns empty string when the Claude session file does not exist", async () => { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fallback-prelude-")); + try { + expect( + buildClaudeCliFallbackContextPrelude({ + cliSessionId: "missing-session", + homeDir: tmpHome, + }), + ).toBe(""); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }); + + it("reads a real Claude JSONL fixture and emits a labeled prelude end-to-end", async () => { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fallback-prelude-")); + const sessionId = "e2e-session"; + const projectsDir = path.join(tmpHome, ".claude", "projects", "demo"); + try { + await fs.mkdir(projectsDir, { recursive: true }); + const lines = [ + { + type: "user", + uuid: "u1", + message: { role: "user", content: "prior question about deploys" }, + }, + { + type: "assistant", + uuid: "a1", + message: { + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "prior answer about blue-green" }], + }, + }, + ]; + await fs.writeFile( + path.join(projectsDir, `${sessionId}.jsonl`), + `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, + "utf-8", + ); + const prelude = buildClaudeCliFallbackContextPrelude({ + cliSessionId: sessionId, + homeDir: tmpHome, + }); + expect(prelude).toContain("## Prior session context (from claude-cli)"); + expect(prelude).toContain("user: prior question about deploys"); + expect(prelude).toContain("assistant: prior answer about blue-green"); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }); }); describe("sessionFileHasContent", () => { diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index be684cce400..6969f2ae9c3 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -23,6 +23,7 @@ import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js"; import { buildWorkspaceSkillSnapshot } from "../skills.js"; import { buildUsageWithNoCost } from "../stream-message-shared.js"; import { + buildClaudeCliFallbackContextPrelude, claudeCliSessionTranscriptHasContent, resolveFallbackRetryPrompt, } from "./attempt-execution.helpers.js"; @@ -259,10 +260,24 @@ export function runAgentAttempt(params: { allowTransientCooldownProbe?: boolean; sessionHasHistory?: boolean; }) { + // #69973: when a fallback fires from claude-cli to a non-CLI candidate + // (or a different CLI backend), the next runner cannot see Claude Code's + // local JSONL history. Without a seed, the fallback model starts cold — + // even though the original Claude session is still alive on disk. + // Harvest a compacted context (Claude's own `/compact` summary plus the + // most recent post-boundary turns) and prepend it to the retry prompt. + // This mirrors what Claude Code itself replays after compaction. + const claudeCliFallbackPrelude = + params.isFallbackRetry && !isClaudeCliProvider(params.providerOverride) + ? buildClaudeCliFallbackContextPrelude({ + cliSessionId: getCliSessionBinding(params.sessionEntry, "claude-cli")?.sessionId, + }) + : ""; const effectivePrompt = resolveFallbackRetryPrompt({ body: params.body, isFallbackRetry: params.isFallbackRetry, sessionHasHistory: params.sessionHasHistory, + priorContextPrelude: claudeCliFallbackPrelude, }); const bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( params.sessionEntry?.systemPromptReport, diff --git a/src/gateway/cli-session-history.claude.ts b/src/gateway/cli-session-history.claude.ts index db2cba974aa..0e0d5afc02b 100644 --- a/src/gateway/cli-session-history.claude.ts +++ b/src/gateway/cli-session-history.claude.ts @@ -333,3 +333,147 @@ export function readClaudeCliSessionMessages(params: { } return coalesceClaudeCliToolMessages(messages); } + +// Compaction surface in Claude Code's JSONL: `/compact` writes a +// `type: "summary"` entry whose `summary` field holds the condensed text, +// and an associated `type: "system", subtype: "compact_boundary"` entry +// whose `compactMetadata` carries `trigger`/`preTokens`. After a boundary, +// only post-compaction `user`/`assistant` turns are written as individual +// entries; pre-compaction context lives in the summary. This shape mirrors +// what Claude Code itself sends to the model after compaction (summary plus +// recent turns), per the upstream session-management docs. +type ClaudeCliCompactBoundaryEntry = { + type: "system"; + subtype?: unknown; + content?: unknown; + timestamp?: unknown; + compactMetadata?: { + trigger?: unknown; + preTokens?: unknown; + }; +}; + +type ClaudeCliSummaryEntry = { + type: "summary"; + summary?: unknown; + leafUuid?: unknown; + timestamp?: unknown; +}; + +export type ClaudeCliFallbackSeed = { + /** + * The most recent compaction summary, if the session has been `/compact`-ed + * at any point. Sourced from the latest `type: "summary"` entry, falling + * back to the latest `compact_boundary` content when no explicit summary + * is present (older Claude Code builds). + */ + summaryText?: string; + /** + * User/assistant turns after the most recent compact boundary, or all + * turns when the session has never been compacted. Tool-result turns are + * coalesced into adjacent assistant turns the same way + * `readClaudeCliSessionMessages` does, so consumers can format them like + * a regular transcript. + */ + recentTurns: TranscriptLikeMessage[]; +}; + +function isCompactBoundary(entry: ClaudeCliProjectEntry): boolean { + if (entry.type !== "system") { + return false; + } + const subtype = (entry as ClaudeCliCompactBoundaryEntry).subtype; + return typeof subtype === "string" && subtype === "compact_boundary"; +} + +function extractCompactBoundaryFallbackText(entry: ClaudeCliProjectEntry): string | undefined { + // When `/compact` is invoked, Claude Code writes a separate summary entry + // — but on older builds the boundary's `content` ("Conversation compacted") + // is the only signal that compaction happened. Prefer the explicit summary + // when both exist; this fallback gives a non-empty hint when only the + // boundary is present so the seed at least labels the gap honestly. + const content = (entry as ClaudeCliCompactBoundaryEntry).content; + return typeof content === "string" && content.trim() ? content.trim() : undefined; +} + +function extractSummaryText(entry: ClaudeCliProjectEntry): string | undefined { + if (entry.type !== "summary") { + return undefined; + } + const summary = (entry as ClaudeCliSummaryEntry).summary; + return typeof summary === "string" && summary.trim() ? summary.trim() : undefined; +} + +export function readClaudeCliFallbackSeed(params: { + cliSessionId: string; + homeDir?: string; +}): ClaudeCliFallbackSeed | undefined { + const filePath = resolveClaudeCliSessionFilePath(params); + if (!filePath) { + return undefined; + } + + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + return undefined; + } + + let summaryText: string | undefined; + let boundaryFallbackText: string | undefined; + // Buffer turns into a window that resets every time we cross a compact + // boundary. After the walk completes, `windowedTurns` holds turns from + // the most recent (post-boundary) window, which matches what Claude Code + // would replay alongside the summary on its own resume. + let windowedTurns: TranscriptLikeMessage[] = []; + const toolNameRegistry: ToolNameRegistry = new Map(); + + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + let parsed: ClaudeCliProjectEntry; + try { + parsed = JSON.parse(line) as ClaudeCliProjectEntry; + } catch { + continue; + } + + const explicitSummary = extractSummaryText(parsed); + if (explicitSummary) { + // Explicit summary entries are written by `/compact`; later entries + // supersede earlier ones the same way Claude Code itself replays + // only the most recent summary. + summaryText = explicitSummary; + continue; + } + + if (isCompactBoundary(parsed)) { + boundaryFallbackText = extractCompactBoundaryFallbackText(parsed) ?? boundaryFallbackText; + // Drop turns that lived before this boundary — they are now + // represented by the summary, and replaying them would double-count + // their tokens against the fallback model's budget. + windowedTurns = []; + // Reset tool-name registry too: tool ids before a compact boundary + // are no longer visible to the post-boundary turns. + toolNameRegistry.clear(); + continue; + } + + const message = parseClaudeCliHistoryEntry(parsed, params.cliSessionId, toolNameRegistry); + if (message) { + windowedTurns.push(message); + } + } + + const recentTurns = coalesceClaudeCliToolMessages(windowedTurns); + const resolvedSummaryText = summaryText ?? boundaryFallbackText; + if (!resolvedSummaryText && recentTurns.length === 0) { + return undefined; + } + return { + ...(resolvedSummaryText ? { summaryText: resolvedSummaryText } : {}), + recentTurns, + }; +} diff --git a/src/gateway/cli-session-history.test.ts b/src/gateway/cli-session-history.test.ts index 005499a7f99..3d4e7232eb9 100644 --- a/src/gateway/cli-session-history.test.ts +++ b/src/gateway/cli-session-history.test.ts @@ -1,10 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { augmentChatHistoryWithCliSessionImports, mergeImportedChatHistoryMessages, + readClaudeCliFallbackSeed, readClaudeCliSessionMessages, resolveClaudeCliSessionFilePath, } from "./cli-session-history.js"; @@ -297,3 +298,217 @@ describe("cli session history", () => { }); }); }); + +// Regression coverage for #69973 — claude-cli fallback context loss. The +// new reader exposes the explicit `/compact` summary and the post-boundary +// turn window so a fallback to a non-CLI candidate can replay the same +// shape Claude Code itself uses on resume after compaction. +describe("readClaudeCliFallbackSeed", () => { + let tmpRoot: string; + let homeDir: string; + let projectsDir: string; + const SESSION_ID = "fallback-seed-session"; + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fallback-seed-")); + homeDir = path.join(tmpRoot, "home"); + projectsDir = path.join(homeDir, ".claude", "projects", "demo-workspace"); + await fs.mkdir(projectsDir, { recursive: true }); + process.env.HOME = homeDir; + }); + + afterEach(async () => { + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + + async function writeJsonl(lines: ReadonlyArray>): Promise { + const file = path.join(projectsDir, `${SESSION_ID}.jsonl`); + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf-8"); + } + + it("returns undefined when the Claude session file does not exist", () => { + const seed = readClaudeCliFallbackSeed({ cliSessionId: SESSION_ID }); + expect(seed).toBeUndefined(); + }); + + it("collects user/assistant turns when the session has never been compacted", async () => { + await writeJsonl([ + { + type: "user", + uuid: "u-1", + message: { role: "user", content: "first user prompt" }, + }, + { + type: "assistant", + uuid: "a-1", + message: { + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "first assistant reply" }], + }, + }, + { + type: "user", + uuid: "u-2", + message: { role: "user", content: "second user prompt" }, + }, + ]); + + const seed = readClaudeCliFallbackSeed({ cliSessionId: SESSION_ID }); + expect(seed).toBeDefined(); + expect(seed?.summaryText).toBeUndefined(); + expect(seed?.recentTurns).toHaveLength(3); + expect(seed?.recentTurns[0]).toMatchObject({ role: "user" }); + expect(seed?.recentTurns[2]).toMatchObject({ role: "user" }); + }); + + it("uses the explicit /compact summary and drops pre-boundary turns", async () => { + await writeJsonl([ + { + type: "user", + uuid: "u-pre", + message: { role: "user", content: "PRE-COMPACT user turn that must NOT be in seed" }, + }, + { + type: "assistant", + uuid: "a-pre", + message: { + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "PRE-COMPACT assistant turn" }], + }, + }, + { + type: "summary", + summary: "User asked about deployment; agent recommended a blue-green strategy.", + leafUuid: "a-pre", + }, + { + type: "system", + subtype: "compact_boundary", + content: "Conversation compacted", + compactMetadata: { trigger: "manual", preTokens: 12345 }, + }, + { + type: "user", + uuid: "u-post", + message: { role: "user", content: "POST-COMPACT user follow-up" }, + }, + { + type: "assistant", + uuid: "a-post", + message: { + role: "assistant", + content: [{ type: "text", text: "POST-COMPACT assistant reply" }], + }, + }, + ]); + + const seed = readClaudeCliFallbackSeed({ cliSessionId: SESSION_ID }); + expect(seed).toBeDefined(); + expect(seed?.summaryText).toBe( + "User asked about deployment; agent recommended a blue-green strategy.", + ); + expect(seed?.recentTurns).toHaveLength(2); + const recentText = JSON.stringify(seed?.recentTurns); + expect(recentText).toContain("POST-COMPACT user follow-up"); + expect(recentText).toContain("POST-COMPACT assistant reply"); + expect(recentText).not.toContain("PRE-COMPACT"); + }); + + it("falls back to compact_boundary content when no explicit summary entry is present", async () => { + await writeJsonl([ + { + type: "user", + uuid: "u-pre", + message: { role: "user", content: "early turn" }, + }, + { + type: "system", + subtype: "compact_boundary", + content: "Conversation compacted", + compactMetadata: { trigger: "auto", preTokens: 50000 }, + }, + { + type: "user", + uuid: "u-post", + message: { role: "user", content: "post-boundary user turn" }, + }, + ]); + + const seed = readClaudeCliFallbackSeed({ cliSessionId: SESSION_ID }); + expect(seed).toBeDefined(); + // Falls back to the boundary's content so the seed at least labels + // that compaction happened, instead of replaying nothing. + expect(seed?.summaryText).toBe("Conversation compacted"); + expect(seed?.recentTurns).toHaveLength(1); + expect(JSON.stringify(seed?.recentTurns)).toContain("post-boundary user turn"); + }); + + it("prefers the most recent summary when the session has been compacted multiple times", async () => { + await writeJsonl([ + { + type: "summary", + summary: "EARLY summary that should be superseded.", + leafUuid: "x", + }, + { + type: "system", + subtype: "compact_boundary", + content: "Conversation compacted", + compactMetadata: { trigger: "manual", preTokens: 1000 }, + }, + { + type: "user", + uuid: "u-mid", + message: { role: "user", content: "mid-window turn" }, + }, + { + type: "summary", + summary: "LATER summary that must win.", + leafUuid: "y", + }, + { + type: "system", + subtype: "compact_boundary", + content: "Conversation compacted", + compactMetadata: { trigger: "manual", preTokens: 2000 }, + }, + { + type: "user", + uuid: "u-tail", + message: { role: "user", content: "tail turn" }, + }, + ]); + + const seed = readClaudeCliFallbackSeed({ cliSessionId: SESSION_ID }); + expect(seed?.summaryText).toBe("LATER summary that must win."); + expect(seed?.recentTurns).toHaveLength(1); + expect(JSON.stringify(seed?.recentTurns)).toContain("tail turn"); + expect(JSON.stringify(seed?.recentTurns)).not.toContain("mid-window turn"); + }); + + it("returns undefined when the session file is empty or has no usable content", async () => { + await writeJsonl([ + // Sidechain entries are filtered out by the underlying parser. + { + type: "user", + uuid: "u-side", + isSidechain: true, + message: { role: "user", content: "sidechain user turn" }, + }, + ]); + const seed = readClaudeCliFallbackSeed({ cliSessionId: SESSION_ID }); + expect(seed).toBeUndefined(); + }); + + it("rejects path-like session ids instead of escaping the Claude projects tree", () => { + const seed = readClaudeCliFallbackSeed({ cliSessionId: "../escape" }); + expect(seed).toBeUndefined(); + }); +}); diff --git a/src/gateway/cli-session-history.ts b/src/gateway/cli-session-history.ts index e1f669eb916..74835cf73c1 100644 --- a/src/gateway/cli-session-history.ts +++ b/src/gateway/cli-session-history.ts @@ -1,7 +1,9 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { SessionEntry } from "../config/sessions.js"; import { + type ClaudeCliFallbackSeed, CLAUDE_CLI_PROVIDER, + readClaudeCliFallbackSeed, readClaudeCliSessionMessages, resolveClaudeCliBindingSessionId, resolveClaudeCliSessionFilePath, @@ -10,9 +12,12 @@ import { mergeImportedChatHistoryMessages } from "./cli-session-history.merge.js export { mergeImportedChatHistoryMessages, + readClaudeCliFallbackSeed, readClaudeCliSessionMessages, + resolveClaudeCliBindingSessionId, resolveClaudeCliSessionFilePath, }; +export type { ClaudeCliFallbackSeed }; export function augmentChatHistoryWithCliSessionImports(params: { entry: SessionEntry | undefined;