mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 04:57:09 +02:00
fix(agents): seed claude-cli fallback prompts with prior-session context (#69973)
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
This commit is contained in:
@@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<FallbackTurnLikeMessage>,
|
||||
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<FallbackTurnLikeMessage>,
|
||||
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() {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>>): Promise<void> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user