diff --git a/README.md b/README.md index 937a3037b..88974280d 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,58 @@ Ask Codex to redesign the database connection to be more resilient. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo +#### Carrying the Claude conversation into the rescue + +By default a rescue only receives the task text you typed. Codex still reads the same repository, +but it does not know what you and Claude already discussed, ruled out, or tried and abandoned. + +Add `--session-context` to attach the current Claude session as a handoff brief: + +```bash +/codex:rescue --session-context fix the failing login test +/codex:rescue --session-context # no task text needed, continues the thread of work +/codex:rescue --session-context --session-context-mode full --background investigate the regression +``` + +The brief is assembled by the companion script straight from the Claude transcript, so Claude does +not have to read or summarize the conversation itself. It contains the compact summary written by +`/compact`, the turns recorded after that summary, and the branch and uncommitted file list at +handoff time. Tool calls, thinking blocks, and subagent chatter are excluded. + +This mirrors how Claude Code survives its own compaction: the compact summary carries the earlier +session and the post-boundary turns close the gap. Running `/compact` before a rescue therefore +produces a denser, cheaper brief than a raw transcript, and it frees your own Claude context at the +same time. + +Modes: + +| Mode | What it attaches | +| --- | --- | +| `--session-context` | compact summary plus the turns recorded after it (the `auto` default) | +| `--session-context-mode summary` | compact summary only | +| `--session-context-mode recent` | recent turns only, no summary | +| `--session-context-mode full` | every turn in the transcript, ignoring the recent-turn cap | +| `--session-context-mode none` | nothing; identical to omitting the flag | + +`--session-context-mode` implies `--session-context`, so you never need both. + +**Notes:** + +- `--session-turns ` caps how many recent turns are attached (default 30). +- `--session-max-chars ` caps the assembled brief (default 49152). Oldest turns are dropped first and the brief says how many were omitted. +- with `--resume`, the `auto` mode narrows to `recent`, because the resumed Codex thread already contains the earlier brief. +- if the compact summary is more than an hour old the plugin says so, so you can run `/compact` for a fresher brief. +- the brief is framed as third-party background, and instructs Codex to verify it against the repository rather than trust it. +- `--source ` overrides transcript discovery, the same way `/codex:transfer` does. + ### `/codex:transfer` Creates a persistent Codex thread from the current Claude Code session and prints a `codex resume ` command. Use it when you started a debugging or implementation conversation in Claude Code and want to continue that same context directly in Codex. +To keep working inside Claude Code and hand Codex a single task with that same context instead of moving over entirely, use [`/codex:rescue --session-context`](#carrying-the-claude-conversation-into-the-rescue). + Examples: ```bash diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86a..6c56e272a 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -31,6 +31,11 @@ Forwarding rules: - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. +- Treat `--session-context`, `--session-context-mode `, `--session-turns `, and `--session-max-chars ` as context controls and do not include them in the task text you pass through. +- `--session-context` is a boolean flag and takes no value. Never consume the following word as its value. +- If the user names a mode such as `summary`, `recent`, or `full`, forward it as `--session-context-mode `. +- Never assemble session context yourself. The companion script reads the Claude transcript and builds the brief. Do not read the transcript, summarize the conversation, or paste history into the task text. +- When `--session-context` is present, an empty task text is valid. Forward the `task` call without a prompt rather than inventing one. - Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 56de9555d..63071081c 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--resume|--fresh] [--session-context] [--session-context-mode ] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- @@ -18,6 +18,9 @@ Execution mode: - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. +- `--session-context`, `--session-context-mode`, `--session-turns`, and `--session-max-chars` are context-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. +- `--session-context` attaches this Claude session's compact summary and recent turns to the Codex prompt so Codex starts with the same background. The companion script assembles it directly from the transcript, so do not read, summarize, or paste the conversation yourself. +- When `--session-context` is present the task text is optional. If the user only says something like "continue" or "take it from here", forward `--session-context` with no task text and let the companion supply the continuation request. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..cc13ed134 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -21,6 +21,7 @@ import { runAppServerReview, runAppServerTurn } from "./lib/codex.mjs"; +import { buildClaudeContextBrief, composeTaskPromptWithBrief, normalizeContextMode } from "./lib/claude-context-brief.mjs"; import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; @@ -54,6 +55,7 @@ import { } from "./lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; import { + renderContextBriefNotice, renderNativeReviewResult, renderReviewResult, renderStoredJobResult, @@ -79,7 +81,7 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--session-context] [--session-context-mode ] [--session-turns ] [--session-max-chars ] [--source ] [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -761,8 +763,17 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["model", "effort", "cwd", "prompt-file"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + valueOptions: [ + "model", + "effort", + "cwd", + "prompt-file", + "session-context-mode", + "session-turns", + "session-max-chars", + "source" + ], + booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background", "session-context"], aliasMap: { m: "model" } @@ -772,16 +783,34 @@ async function handleTask(argv) { const workspaceRoot = resolveCommandWorkspace(options); const model = normalizeRequestedModel(options.model); const effort = normalizeReasoningEffort(options.effort); - const prompt = readTaskPrompt(cwd, options, positionals); + const requestedPrompt = readTaskPrompt(cwd, options, positionals); const resumeLast = Boolean(options["resume-last"] || options.resume); const fresh = Boolean(options.fresh); if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } + + // A resumed thread already carries the earlier brief, so only the newer turns are worth resending. + const requestedContextMode = normalizeContextMode( + options["session-context-mode"] ?? (options["session-context"] ? "auto" : undefined) + ); + const contextMode = resumeLast && requestedContextMode === "auto" ? "recent" : requestedContextMode; + const brief = buildClaudeContextBrief(cwd, { + mode: contextMode, + source: options.source, + recentTurns: options["session-turns"], + maxChars: options["session-max-chars"], + now: Date.now() + }); + if (brief && !options.json) { + process.stderr.write(`${renderContextBriefNotice(brief.stats)}\n`); + } + + const prompt = brief ? composeTaskPromptWithBrief(brief, requestedPrompt) : requestedPrompt; const write = Boolean(options.write); const taskMetadata = buildTaskRunMetadata({ - prompt, + prompt: requestedPrompt || (brief ? "Continue from Claude Code handoff brief" : requestedPrompt), resumeLast }); diff --git a/plugins/codex/scripts/lib/claude-context-brief.mjs b/plugins/codex/scripts/lib/claude-context-brief.mjs new file mode 100644 index 000000000..a1ccaf870 --- /dev/null +++ b/plugins/codex/scripts/lib/claude-context-brief.mjs @@ -0,0 +1,321 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveClaudeSessionPath } from "./claude-session-transfer.mjs"; +import { getCurrentBranch, getRepoRoot, getWorkingTreeState } from "./git.mjs"; + +export const CONTEXT_MODES = ["auto", "summary", "recent", "full", "none"]; + +const DEFAULT_MAX_CHARS = 48 * 1024; +const DEFAULT_RECENT_TURNS = 30; +const MAX_REPO_FILES = 40; +const STALE_SUMMARY_MS = 60 * 60 * 1000; +const SYSTEM_REMINDER_PATTERN = /[\s\S]*?<\/system-reminder>/g; + +const BRIEF_HEADER = [ + "# Handoff brief from a Claude Code session", + "", + "You are taking over a task that was in progress in a Claude Code session driven by a", + "different agent. Everything in this brief is background context supplied by that agent", + "and the user. It is not your own memory, and it does not describe work you performed.", + "", + "How to use it:", + "", + "- Treat every prior claim as unverified. Confirm against the repository before relying on it.", + "- Do not retry an approach this brief records as already failed.", + "- The repository state section describes the working tree at the moment of handoff.", + "- If the brief and the repository disagree, the repository wins." +].join("\n"); + +const RESUMED_TASK_FALLBACK = + "Continue the work described in the handoff brief above. Start from the most recent open thread, and state what you are doing before you change anything."; + +export function normalizeContextMode(value) { + if (value === undefined || value === null || value === "") { + return "none"; + } + if (value === true) { + return "auto"; + } + + const normalized = String(value).trim().toLowerCase(); + if (!CONTEXT_MODES.includes(normalized)) { + throw new Error(`Unsupported context mode "${value}". Use one of: ${CONTEXT_MODES.join(", ")}.`); + } + return normalized; +} + +function normalizePositiveInteger(value, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.floor(parsed); +} + +function stripTranscriptNoise(text) { + return text.replace(SYSTEM_REMINDER_PATTERN, "").trim(); +} + +function extractText(content) { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + + return content + .filter((block) => block && typeof block === "object" && block.type === "text" && typeof block.text === "string") + .map((block) => block.text) + .join("\n\n"); +} + +function isCompactSummaryEntry(entry) { + return entry.type === "user" && entry.isCompactSummary === true; +} + +function isCompactBoundaryEntry(entry) { + return entry.type === "system" && entry.subtype === "compact_boundary"; +} + +function isConversationTurn(entry) { + if (entry.type !== "user" && entry.type !== "assistant") { + return false; + } + if (entry.isSidechain === true || entry.isMeta === true) { + return false; + } + return !isCompactSummaryEntry(entry); +} + +function readTranscriptEntries(transcriptPath) { + const raw = fs.readFileSync(transcriptPath, "utf8"); + const entries = []; + + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + entries.push(JSON.parse(trimmed)); + } catch { + // A transcript being appended to while we read it can end in a partial line. Skip it. + } + } + + return entries; +} + +function parseTranscript(transcriptPath) { + const entries = readTranscriptEntries(transcriptPath); + let summary = null; + let summaryTimestamp = null; + let boundaryIndex = -1; + const turns = []; + + entries.forEach((entry, index) => { + if (isCompactBoundaryEntry(entry)) { + boundaryIndex = index; + return; + } + + if (isCompactSummaryEntry(entry)) { + const text = stripTranscriptNoise(extractText(entry.message?.content)); + if (text) { + summary = text; + summaryTimestamp = entry.timestamp ?? null; + boundaryIndex = index; + } + return; + } + + if (!isConversationTurn(entry)) { + return; + } + + const text = stripTranscriptNoise(extractText(entry.message?.content)); + if (!text) { + return; + } + + turns.push({ index, role: entry.type, text }); + }); + + return { + summary, + summaryTimestamp, + turnsAfterSummary: turns.filter((turn) => turn.index > boundaryIndex), + allTurns: turns + }; +} + +function formatTurns(turns) { + return turns.map((turn) => `### ${turn.role === "user" ? "User" : "Assistant"}\n\n${turn.text}`).join("\n\n"); +} + +function selectTurns(turns, { limit, maxChars }) { + const bounded = limit ? turns.slice(-limit) : turns.slice(); + const selected = []; + let usedChars = 0; + + for (let index = bounded.length - 1; index >= 0; index -= 1) { + const turn = bounded[index]; + const cost = turn.text.length + 32; + if (selected.length > 0 && usedChars + cost > maxChars) { + break; + } + usedChars += cost; + selected.unshift(turn); + } + + return { + turns: selected, + droppedTurnCount: turns.length - selected.length + }; +} + +function collectRepoState(cwd) { + let repoRoot; + try { + repoRoot = getRepoRoot(cwd); + } catch { + return null; + } + + try { + const branch = getCurrentBranch(repoRoot); + const state = getWorkingTreeState(repoRoot); + const files = [...new Set([...state.staged, ...state.unstaged, ...state.untracked])].sort(); + const shown = files.slice(0, MAX_REPO_FILES); + const hiddenCount = files.length - shown.length; + + return { + repoRoot, + branch, + isDirty: state.isDirty, + fileCount: files.length, + files: shown, + hiddenCount + }; + } catch { + return null; + } +} + +function formatRepoState(repo) { + if (!repo) { + return null; + } + + const lines = [`- Branch: \`${repo.branch}\``]; + if (!repo.isDirty) { + lines.push("- Working tree: clean"); + return lines.join("\n"); + } + + lines.push(`- Working tree: ${repo.fileCount} uncommitted file(s)`); + lines.push(...repo.files.map((file) => ` - \`${file}\``)); + if (repo.hiddenCount > 0) { + lines.push(` - …and ${repo.hiddenCount} more`); + } + lines.push("- Inspect the actual changes yourself with read-only git commands before editing."); + return lines.join("\n"); +} + +function isStaleSummary(summaryTimestamp, now) { + if (!summaryTimestamp || !now) { + return false; + } + const summaryTime = Date.parse(summaryTimestamp); + if (!Number.isFinite(summaryTime)) { + return false; + } + return now - summaryTime > STALE_SUMMARY_MS; +} + +/** + * Assemble a Codex-facing brief from the current Claude Code transcript. + * + * Mirrors how Claude Code itself survives compaction: the compact summary carries the + * earlier session, and the turns recorded after the compact boundary close the gap. + */ +export function buildClaudeContextBrief(cwd, options = {}) { + const mode = normalizeContextMode(options.mode); + if (mode === "none") { + return null; + } + + const transcriptPath = resolveClaudeSessionPath(cwd, { source: options.source }); + const maxChars = normalizePositiveInteger(options.maxChars, DEFAULT_MAX_CHARS); + const recentTurns = normalizePositiveInteger(options.recentTurns, DEFAULT_RECENT_TURNS); + const { summary, summaryTimestamp, turnsAfterSummary, allTurns } = parseTranscript(transcriptPath); + + const includeSummary = Boolean(summary) && (mode === "auto" || mode === "summary"); + const sourceTurns = mode === "full" || mode === "recent" ? allTurns : turnsAfterSummary; + const includeTurns = mode !== "summary"; + + const summaryChars = includeSummary ? summary.length : 0; + const turnBudget = Math.max(maxChars - summaryChars, Math.floor(maxChars / 4)); + const selection = includeTurns + ? selectTurns(sourceTurns, { limit: mode === "full" ? null : recentTurns, maxChars: turnBudget }) + : { turns: [], droppedTurnCount: sourceTurns.length }; + + if (!includeSummary && selection.turns.length === 0) { + throw new Error( + `No usable conversation found in ${transcriptPath}. Run this from an active Claude Code session, or pass --session-context none.` + ); + } + + const repo = collectRepoState(cwd); + const sections = [BRIEF_HEADER]; + + if (includeSummary) { + sections.push(["## Session summary", "", "Compacted by Claude Code earlier in the session.", "", summary].join("\n")); + } + + if (selection.turns.length > 0) { + const heading = includeSummary ? "## Conversation after that summary" : "## Recent conversation"; + const preamble = + selection.droppedTurnCount > 0 + ? `Most recent ${selection.turns.length} turn(s). ${selection.droppedTurnCount} earlier turn(s) omitted for length.` + : `${selection.turns.length} turn(s), oldest first.`; + sections.push([heading, "", preamble, "", formatTurns(selection.turns)].join("\n")); + } + + const repoSection = formatRepoState(repo); + if (repoSection) { + sections.push(["## Repository state at handoff", "", repoSection].join("\n")); + } + + const text = sections.join("\n\n"); + const stale = isStaleSummary(summaryTimestamp, options.now); + + return { + text, + stats: { + transcriptPath, + sessionId: path.basename(transcriptPath, ".jsonl"), + mode, + hasSummary: includeSummary, + summaryChars, + summaryTimestamp, + staleSummary: stale, + includedTurnCount: selection.turns.length, + droppedTurnCount: selection.droppedTurnCount, + repoRoot: repo?.repoRoot ?? null, + branch: repo?.branch ?? null, + totalChars: text.length, + truncated: selection.droppedTurnCount > 0 + } + }; +} + +export function composeTaskPromptWithBrief(brief, prompt) { + const request = prompt && prompt.trim() ? prompt.trim() : RESUMED_TASK_FALLBACK; + if (!brief) { + return request; + } + return [brief.text, "", "---", "", "## Task", "", request].join("\n"); +} diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs index eea0aeba2..39cf85974 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs @@ -5,7 +5,11 @@ import path from "node:path"; import { ensureAbsolutePath } from "./fs.mjs"; export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH"; -const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); + +// Resolved per call so tests can relocate the home directory. +function claudeProjectsDir() { + return path.join(os.homedir(), ".claude", "projects"); +} function resolveUserPath(cwd, value) { if (value === "~") { @@ -28,17 +32,18 @@ export function resolveClaudeSessionPath(cwd, options = {}) { throw new Error(`Claude session source must be a JSONL file: ${sourcePath}`); } + const projectsDir = claudeProjectsDir(); let source; let projects; try { source = fs.realpathSync(sourcePath); - projects = fs.realpathSync(CLAUDE_PROJECTS_DIR); + projects = fs.realpathSync(projectsDir); } catch { throw new Error(`Claude session file not found: ${sourcePath}`); } const relative = path.relative(projects, source); if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - throw new Error(`Codex can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`); + throw new Error(`Codex can import Claude sessions only from ${projectsDir}: ${source}`); } return source; } diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec185236..295d2309b 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -322,6 +322,22 @@ export function renderTaskResult(parsedResult, meta) { return `${message}\n`; } +export function renderContextBriefNotice(stats) { + const parts = [`mode ${stats.mode}`]; + parts.push(stats.hasSummary ? "compact summary included" : "no compact summary"); + parts.push(`${stats.includedTurnCount} turn(s)`); + if (stats.droppedTurnCount > 0) { + parts.push(`${stats.droppedTurnCount} omitted`); + } + parts.push(`${Math.round(stats.totalChars / 1024)} KiB`); + + const lines = [`[codex] Attached Claude session context: ${parts.join(", ")}.`]; + if (stats.staleSummary) { + lines.push("[codex] The compact summary is over an hour old. Run /compact for a fresher brief."); + } + return lines.join("\n"); +} + export function renderStatusReport(report) { const lines = [ "# Codex Status", diff --git a/tests/claude-context-brief.test.mjs b/tests/claude-context-brief.test.mjs new file mode 100644 index 000000000..52d9348e7 --- /dev/null +++ b/tests/claude-context-brief.test.mjs @@ -0,0 +1,376 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildClaudeContextBrief, + composeTaskPromptWithBrief, + CONTEXT_MODES, + normalizeContextMode +} from "../plugins/codex/scripts/lib/claude-context-brief.mjs"; +import { parseArgs } from "../plugins/codex/scripts/lib/args.mjs"; +import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; + +// Mirrors the option shape handleTask passes to parseCommandInput. +const TASK_ARG_CONFIG = { + valueOptions: ["model", "effort", "cwd", "prompt-file", "session-context-mode", "session-turns", "session-max-chars", "source"], + booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background", "session-context"], + aliasMap: { m: "model" } +}; + +const HOME_KEYS = ["HOME", "USERPROFILE"]; + +function withFakeHome(callback) { + const home = makeTempDir("codex-plugin-home-"); + const previous = HOME_KEYS.map((key) => [key, process.env[key]]); + for (const key of HOME_KEYS) { + process.env[key] = home; + } + try { + return callback(home); + } finally { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function writeTranscript(home, sessionId, entries) { + const projectDir = path.join(home, ".claude", "projects", "-tmp-project"); + fs.mkdirSync(projectDir, { recursive: true }); + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`); + fs.writeFileSync(transcriptPath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + return transcriptPath; +} + +function userTurn(text) { + return { type: "user", message: { role: "user", content: text } }; +} + +function assistantTurn(text) { + return { type: "assistant", message: { role: "assistant", content: [{ type: "text", text }] } }; +} + +function compactSummary(text, timestamp = "2026-01-01T00:00:00.000Z") { + return { + type: "user", + isCompactSummary: true, + timestamp, + message: { role: "user", content: text } + }; +} + +test("normalizeContextMode maps absence and shorthand", () => { + assert.equal(normalizeContextMode(undefined), "none"); + assert.equal(normalizeContextMode(""), "none"); + assert.equal(normalizeContextMode(true), "auto"); + assert.equal(normalizeContextMode("Summary"), "summary"); + for (const mode of CONTEXT_MODES) { + assert.equal(normalizeContextMode(mode), mode); + } + assert.throws(() => normalizeContextMode("everything"), /Unsupported context mode/); +}); + +test("buildClaudeContextBrief returns null when context is off", () => { + assert.equal(buildClaudeContextBrief(process.cwd(), { mode: "none" }), null); + assert.equal(buildClaudeContextBrief(process.cwd(), {}), null); +}); + +test("auto mode keeps the compact summary and only the turns after it", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-auto", [ + userTurn("pre-compact question"), + assistantTurn("pre-compact answer"), + { type: "system", subtype: "compact_boundary", isMeta: true }, + compactSummary("1. Primary Request and Intent: fix the failing login test."), + userTurn("after compact question"), + assistantTurn("after compact answer") + ]); + + const brief = buildClaudeContextBrief(makeTempDir(), { mode: "auto", source: transcriptPath }); + + assert.equal(brief.stats.hasSummary, true); + assert.equal(brief.stats.includedTurnCount, 2); + assert.match(brief.text, /fix the failing login test/); + assert.match(brief.text, /after compact question/); + assert.doesNotMatch(brief.text, /pre-compact question/); + assert.match(brief.text, /Conversation after that summary/); + }); +}); + +test("summary mode omits conversation turns and recent mode omits the summary", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-modes", [ + userTurn("older question"), + compactSummary("summary body text"), + userTurn("newer question") + ]); + const cwd = makeTempDir(); + + const summaryOnly = buildClaudeContextBrief(cwd, { mode: "summary", source: transcriptPath }); + assert.equal(summaryOnly.stats.includedTurnCount, 0); + assert.match(summaryOnly.text, /summary body text/); + assert.doesNotMatch(summaryOnly.text, /newer question/); + + const recentOnly = buildClaudeContextBrief(cwd, { mode: "recent", source: transcriptPath }); + assert.equal(recentOnly.stats.hasSummary, false); + assert.doesNotMatch(recentOnly.text, /summary body text/); + assert.match(recentOnly.text, /older question/); + assert.match(recentOnly.text, /newer question/); + }); +}); + +test("sidechain, meta, and tool-only entries are excluded", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-noise", [ + userTurn("real question"), + { type: "user", isSidechain: true, message: { role: "user", content: "subagent chatter" } }, + { type: "assistant", isMeta: true, message: { role: "assistant", content: [{ type: "text", text: "meta note" }] } }, + { + type: "user", + message: { role: "user", content: [{ type: "tool_result", content: "tool output blob" }] } + }, + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "private reasoning" }, + { type: "tool_use", name: "Read", input: {} }, + { type: "text", text: "real answer" } + ] + } + }, + { type: "file-history-snapshot", payload: {} } + ]); + + const brief = buildClaudeContextBrief(makeTempDir(), { mode: "recent", source: transcriptPath }); + + assert.equal(brief.stats.includedTurnCount, 2); + assert.match(brief.text, /real question/); + assert.match(brief.text, /real answer/); + for (const noise of ["subagent chatter", "meta note", "tool output blob", "private reasoning"]) { + assert.doesNotMatch(brief.text, new RegExp(noise)); + } + }); +}); + +test("system-reminder blocks are stripped from turn text", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-reminder", [ + userTurn("keep this drop this injected note") + ]); + + const brief = buildClaudeContextBrief(makeTempDir(), { mode: "recent", source: transcriptPath }); + + assert.match(brief.text, /keep this/); + assert.doesNotMatch(brief.text, /drop this injected note/); + }); +}); + +test("oversized transcripts drop the oldest turns and report truncation", () => { + withFakeHome((home) => { + const filler = "x".repeat(4000); + const transcriptPath = writeTranscript(home, "session-large", [ + userTurn(`oldest ${filler}`), + userTurn(`middle ${filler}`), + userTurn(`newest ${filler}`) + ]); + + const brief = buildClaudeContextBrief(makeTempDir(), { + mode: "recent", + source: transcriptPath, + maxChars: 9000 + }); + + assert.equal(brief.stats.truncated, true); + assert.ok(brief.stats.droppedTurnCount >= 1); + assert.match(brief.text, /newest/); + assert.doesNotMatch(brief.text, /oldest/); + assert.match(brief.text, /earlier turn\(s\) omitted for length/); + }); +}); + +test("context-turns caps how many recent turns are attached", () => { + withFakeHome((home) => { + const entries = []; + for (let index = 0; index < 10; index += 1) { + entries.push(userTurn(`turn number ${index}`)); + } + const transcriptPath = writeTranscript(home, "session-limit", entries); + + const brief = buildClaudeContextBrief(makeTempDir(), { + mode: "recent", + source: transcriptPath, + recentTurns: 3 + }); + + assert.equal(brief.stats.includedTurnCount, 3); + assert.match(brief.text, /turn number 9/); + assert.doesNotMatch(brief.text, /turn number 6/); + }); +}); + +test("full mode ignores the recent-turn cap and the summary", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-full", [ + userTurn("first"), + compactSummary("summary body"), + userTurn("second"), + userTurn("third") + ]); + + const brief = buildClaudeContextBrief(makeTempDir(), { + mode: "full", + source: transcriptPath, + recentTurns: 1 + }); + + assert.equal(brief.stats.hasSummary, false); + assert.equal(brief.stats.includedTurnCount, 3); + assert.match(brief.text, /first/); + assert.match(brief.text, /third/); + }); +}); + +test("repository state is attached when the cwd is a git repo", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-repo", [userTurn("look at my branch")]); + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + run("git", ["checkout", "-b", "feature/handoff"], { cwd }); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v2');\n"); + + const brief = buildClaudeContextBrief(cwd, { mode: "recent", source: transcriptPath }); + + assert.equal(brief.stats.branch, "feature/handoff"); + assert.match(brief.text, /Repository state at handoff/); + assert.match(brief.text, /feature\/handoff/); + assert.match(brief.text, /app\.js/); + }); +}); + +test("a non-git directory still produces a brief without repository state", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-nogit", [userTurn("no repo here")]); + + const brief = buildClaudeContextBrief(makeTempDir(), { mode: "recent", source: transcriptPath }); + + assert.equal(brief.stats.branch, null); + assert.doesNotMatch(brief.text, /Repository state at handoff/); + }); +}); + +test("a stale compact summary is flagged", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-stale", [ + compactSummary("old summary", "2026-01-01T00:00:00.000Z"), + userTurn("still going") + ]); + + const stale = buildClaudeContextBrief(makeTempDir(), { + mode: "auto", + source: transcriptPath, + now: Date.parse("2026-01-01T05:00:00.000Z") + }); + assert.equal(stale.stats.staleSummary, true); + + const fresh = buildClaudeContextBrief(makeTempDir(), { + mode: "auto", + source: transcriptPath, + now: Date.parse("2026-01-01T00:05:00.000Z") + }); + assert.equal(fresh.stats.staleSummary, false); + }); +}); + +test("a transcript with no usable conversation is rejected", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-empty", [ + { type: "file-history-snapshot", payload: {} }, + { type: "queue-operation", payload: {} } + ]); + + assert.throws( + () => buildClaudeContextBrief(makeTempDir(), { mode: "auto", source: transcriptPath }), + /No usable conversation found/ + ); + }); +}); + +test("a truncated trailing line does not break parsing", () => { + withFakeHome((home) => { + const transcriptPath = writeTranscript(home, "session-partial", [userTurn("complete turn")]); + fs.appendFileSync(transcriptPath, '{"type":"assistant","message":{"role":"assist'); + + const brief = buildClaudeContextBrief(makeTempDir(), { mode: "recent", source: transcriptPath }); + + assert.equal(brief.stats.includedTurnCount, 1); + }); +}); + +test("transcripts outside the Claude projects directory are refused", () => { + withFakeHome((home) => { + writeTranscript(home, "session-inside", [userTurn("inside")]); + const outside = path.join(makeTempDir(), "elsewhere.jsonl"); + fs.writeFileSync(outside, `${JSON.stringify(userTurn("hi"))}\n`); + + assert.throws( + () => buildClaudeContextBrief(makeTempDir(), { mode: "auto", source: outside }), + /only from/ + ); + }); +}); + +test("--session-context is boolean and never swallows the task text", () => { + const { options, positionals } = parseArgs( + ["--session-context", "fix", "the", "failing", "login", "test"], + TASK_ARG_CONFIG + ); + + assert.equal(options["session-context"], true); + assert.equal(options["session-context-mode"], undefined); + assert.deepEqual(positionals, ["fix", "the", "failing", "login", "test"]); + assert.equal(normalizeContextMode(options["session-context-mode"] ?? (options["session-context"] ? "auto" : undefined)), "auto"); +}); + +test("--session-context-mode takes a value and implies the flag", () => { + const { options, positionals } = parseArgs( + ["--session-context-mode", "full", "investigate", "the", "regression"], + TASK_ARG_CONFIG + ); + + assert.equal(options["session-context-mode"], "full"); + assert.deepEqual(positionals, ["investigate", "the", "regression"]); + assert.equal(normalizeContextMode(options["session-context-mode"] ?? (options["session-context"] ? "auto" : undefined)), "full"); +}); + +test("omitting both context flags leaves context off", () => { + const { options, positionals } = parseArgs(["fix", "the", "bug"], TASK_ARG_CONFIG); + + assert.equal(normalizeContextMode(options["session-context-mode"] ?? (options["session-context"] ? "auto" : undefined)), "none"); + assert.deepEqual(positionals, ["fix", "the", "bug"]); +}); + +test("composeTaskPromptWithBrief frames the brief and appends the request", () => { + const brief = { text: "BRIEF BODY", stats: {} }; + + const withRequest = composeTaskPromptWithBrief(brief, "fix the login"); + assert.match(withRequest, /BRIEF BODY/); + assert.match(withRequest, /## Task/); + assert.match(withRequest, /fix the login/); + + const withoutRequest = composeTaskPromptWithBrief(brief, " "); + assert.match(withoutRequest, /Continue the work described in the handoff brief/); + + assert.equal(composeTaskPromptWithBrief(null, "just this"), "just this"); +});