mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:20:44 +00: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,
|
startsWithSilentToken,
|
||||||
stripLeadingSilentToken,
|
stripLeadingSilentToken,
|
||||||
} from "../../auto-reply/tokens.js";
|
} 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. */
|
/** Maximum number of JSONL records to inspect before giving up. */
|
||||||
const SESSION_FILE_MAX_RECORDS = 500;
|
const SESSION_FILE_MAX_RECORDS = 500;
|
||||||
@@ -105,11 +109,21 @@ export function resolveFallbackRetryPrompt(params: {
|
|||||||
body: string;
|
body: string;
|
||||||
isFallbackRetry: boolean;
|
isFallbackRetry: boolean;
|
||||||
sessionHasHistory?: 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 {
|
}): string {
|
||||||
if (!params.isFallbackRetry) {
|
if (!params.isFallbackRetry) {
|
||||||
return params.body;
|
return params.body;
|
||||||
}
|
}
|
||||||
if (!params.sessionHasHistory) {
|
const prelude = params.priorContextPrelude?.trim();
|
||||||
|
if (!params.sessionHasHistory && !prelude) {
|
||||||
return params.body;
|
return params.body;
|
||||||
}
|
}
|
||||||
// Even with persisted session history, fully replacing the body with a
|
// 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
|
// instruction from history alone, which is fragile and sometimes
|
||||||
// impossible. Prepend the retry context to the original body instead so
|
// impossible. Prepend the retry context to the original body instead so
|
||||||
// the fallback model has both the recovery signal AND the task. (#65760)
|
// 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() {
|
export function createAcpVisibleTextAccumulator() {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildClaudeCliFallbackContextPrelude,
|
||||||
claudeCliSessionTranscriptHasContent,
|
claudeCliSessionTranscriptHasContent,
|
||||||
createAcpVisibleTextAccumulator,
|
createAcpVisibleTextAccumulator,
|
||||||
|
formatClaudeCliFallbackPrelude,
|
||||||
resolveFallbackRetryPrompt,
|
resolveFallbackRetryPrompt,
|
||||||
sessionFileHasContent,
|
sessionFileHasContent,
|
||||||
} from "./attempt-execution.helpers.js";
|
} from "./attempt-execution.helpers.js";
|
||||||
@@ -77,6 +79,193 @@ describe("resolveFallbackRetryPrompt", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(originalBody);
|
).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", () => {
|
describe("sessionFileHasContent", () => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
|
|||||||
import { buildWorkspaceSkillSnapshot } from "../skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../skills.js";
|
||||||
import { buildUsageWithNoCost } from "../stream-message-shared.js";
|
import { buildUsageWithNoCost } from "../stream-message-shared.js";
|
||||||
import {
|
import {
|
||||||
|
buildClaudeCliFallbackContextPrelude,
|
||||||
claudeCliSessionTranscriptHasContent,
|
claudeCliSessionTranscriptHasContent,
|
||||||
resolveFallbackRetryPrompt,
|
resolveFallbackRetryPrompt,
|
||||||
} from "./attempt-execution.helpers.js";
|
} from "./attempt-execution.helpers.js";
|
||||||
@@ -259,10 +260,24 @@ export function runAgentAttempt(params: {
|
|||||||
allowTransientCooldownProbe?: boolean;
|
allowTransientCooldownProbe?: boolean;
|
||||||
sessionHasHistory?: 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({
|
const effectivePrompt = resolveFallbackRetryPrompt({
|
||||||
body: params.body,
|
body: params.body,
|
||||||
isFallbackRetry: params.isFallbackRetry,
|
isFallbackRetry: params.isFallbackRetry,
|
||||||
sessionHasHistory: params.sessionHasHistory,
|
sessionHasHistory: params.sessionHasHistory,
|
||||||
|
priorContextPrelude: claudeCliFallbackPrelude,
|
||||||
});
|
});
|
||||||
const bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
|
const bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
|
||||||
params.sessionEntry?.systemPromptReport,
|
params.sessionEntry?.systemPromptReport,
|
||||||
|
|||||||
@@ -333,3 +333,147 @@ export function readClaudeCliSessionMessages(params: {
|
|||||||
}
|
}
|
||||||
return coalesceClaudeCliToolMessages(messages);
|
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 fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
augmentChatHistoryWithCliSessionImports,
|
augmentChatHistoryWithCliSessionImports,
|
||||||
mergeImportedChatHistoryMessages,
|
mergeImportedChatHistoryMessages,
|
||||||
|
readClaudeCliFallbackSeed,
|
||||||
readClaudeCliSessionMessages,
|
readClaudeCliSessionMessages,
|
||||||
resolveClaudeCliSessionFilePath,
|
resolveClaudeCliSessionFilePath,
|
||||||
} from "./cli-session-history.js";
|
} 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 { normalizeProviderId } from "../agents/model-selection.js";
|
||||||
import type { SessionEntry } from "../config/sessions.js";
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
import {
|
import {
|
||||||
|
type ClaudeCliFallbackSeed,
|
||||||
CLAUDE_CLI_PROVIDER,
|
CLAUDE_CLI_PROVIDER,
|
||||||
|
readClaudeCliFallbackSeed,
|
||||||
readClaudeCliSessionMessages,
|
readClaudeCliSessionMessages,
|
||||||
resolveClaudeCliBindingSessionId,
|
resolveClaudeCliBindingSessionId,
|
||||||
resolveClaudeCliSessionFilePath,
|
resolveClaudeCliSessionFilePath,
|
||||||
@@ -10,9 +12,12 @@ import { mergeImportedChatHistoryMessages } from "./cli-session-history.merge.js
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
mergeImportedChatHistoryMessages,
|
mergeImportedChatHistoryMessages,
|
||||||
|
readClaudeCliFallbackSeed,
|
||||||
readClaudeCliSessionMessages,
|
readClaudeCliSessionMessages,
|
||||||
|
resolveClaudeCliBindingSessionId,
|
||||||
resolveClaudeCliSessionFilePath,
|
resolveClaudeCliSessionFilePath,
|
||||||
};
|
};
|
||||||
|
export type { ClaudeCliFallbackSeed };
|
||||||
|
|
||||||
export function augmentChatHistoryWithCliSessionImports(params: {
|
export function augmentChatHistoryWithCliSessionImports(params: {
|
||||||
entry: SessionEntry | undefined;
|
entry: SessionEntry | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user