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:
stainlu
2026-04-26 16:45:03 +08:00
committed by Ayaan Zaidi
parent 290c7ab848
commit a96f1fa5ef
6 changed files with 743 additions and 3 deletions

View File

@@ -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() {

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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();
});
});

View File

@@ -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;