From c9663131e89c0bed4a765bb1b90888ea9fd81252 Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Fri, 17 Apr 2026 14:09:48 +0900 Subject: [PATCH 1/5] feat(session): add LLM-driven history summarization module --- packages/cli/src/session/summarize-history.ts | 80 +++++++++++++ packages/cli/test/summarize-history.test.ts | 106 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 packages/cli/src/session/summarize-history.ts create mode 100644 packages/cli/test/summarize-history.test.ts diff --git a/packages/cli/src/session/summarize-history.ts b/packages/cli/src/session/summarize-history.ts new file mode 100644 index 0000000..10627cd --- /dev/null +++ b/packages/cli/src/session/summarize-history.ts @@ -0,0 +1,80 @@ +import type { AgentHistoryItem, LLMProvider } from "../llm/types.js"; +import { segmentHistoryByUserTurns } from "./compact-history.js"; + +export type SummarizeOptions = { + provider: LLMProvider; + /** User-led turns to keep unsummarized from the end (default: 4) */ + keepRecentTurns?: number; +}; + +/** Render history items as readable text for the summarization prompt. */ +export function renderHistoryAsText(history: AgentHistoryItem[]): string { + const parts: string[] = []; + for (const item of history) { + if (item.kind === "user_text") { + parts.push(`User: ${item.text}`); + } else if (item.kind === "assistant") { + const textParts = item.blocks + .filter((b) => b.type === "text") + .map((b) => (b.type === "text" ? b.text : "")); + const toolNames = item.blocks + .filter((b) => b.type === "tool_use") + .map((b) => (b.type === "tool_use" ? `[called ${b.name}]` : "")); + const combined = [...textParts, ...toolNames].join(" ").trim(); + parts.push(`Assistant: ${combined}`); + } else { + // tool_outputs + const preview = item.outputs + .map((o) => o.content.slice(0, 200)) + .join("; "); + parts.push(`Tool results: ${preview}`); + } + } + return parts.join("\n\n"); +} + +/** + * Summarize old conversation turns using the LLM. + * Replaces all turns older than `keepRecentTurns` user-led segments with a + * single `[Session context summary: …]` user_text item. + * Returns the original reference unchanged when there is nothing old to summarize. + */ +export async function summarizeHistory( + history: AgentHistoryItem[], + opts: SummarizeOptions, +): Promise { + const keepRecentTurns = opts.keepRecentTurns ?? 4; + const segments = segmentHistoryByUserTurns(history); + + if (segments.length <= keepRecentTurns) return history; + + const oldHistory = segments.slice(0, -keepRecentTurns).flat(); + const recentHistory = segments.slice(-keepRecentTurns).flat(); + + const summaryText = await callLLMForSummary(oldHistory, opts.provider); + + return [ + { kind: "user_text", text: `[Session context summary: ${summaryText}]` }, + ...recentHistory, + ]; +} + +async function callLLMForSummary( + history: AgentHistoryItem[], + provider: LLMProvider, +): Promise { + const rendered = renderHistoryAsText(history); + const result = await provider.complete({ + system: + "You are a conversation summarizer. Summarize the following conversation history " + + "in 2-4 concise paragraphs. Preserve: the user's goals, key decisions made, " + + "files read or changed, and current state. Be factual and brief. " + + "Output only the summary, no preamble.", + history: [ + { kind: "user_text", text: `Summarize this conversation:\n\n${rendered}` }, + ], + tools: [], + }); + const textBlock = result.blocks.find((b) => b.type === "text"); + return textBlock?.type === "text" ? textBlock.text : "No summary available."; +} diff --git a/packages/cli/test/summarize-history.test.ts b/packages/cli/test/summarize-history.test.ts new file mode 100644 index 0000000..ee5af0f --- /dev/null +++ b/packages/cli/test/summarize-history.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + renderHistoryAsText, + summarizeHistory, +} from "../src/session/summarize-history.js"; +import type { AgentHistoryItem, LLMProvider } from "../src/llm/types.js"; + +function u(t: string): AgentHistoryItem { return { kind: "user_text", text: t }; } +function a(text: string): AgentHistoryItem { + return { kind: "assistant", blocks: [{ type: "text", text }] }; +} +function tool(id: string, content: string): AgentHistoryItem { + return { kind: "tool_outputs", outputs: [{ id, content }] }; +} + +function mockProvider(reply: string): LLMProvider { + return { + async complete() { + return { stopReason: "end_turn", blocks: [{ type: "text", text: reply }] }; + }, + }; +} + +describe("renderHistoryAsText", () => { + it("renders user and assistant items", () => { + const h: AgentHistoryItem[] = [u("hello"), a("world")]; + const out = renderHistoryAsText(h); + expect(out).toContain("User: hello"); + expect(out).toContain("Assistant: world"); + }); + + it("renders tool_use names in assistant blocks", () => { + const h: AgentHistoryItem[] = [ + { + kind: "assistant", + blocks: [ + { type: "text", text: "Reading..." }, + { type: "tool_use", id: "t1", name: "read_file", input: { path: "x.ts" } }, + ], + }, + ]; + const out = renderHistoryAsText(h); + expect(out).toContain("[called read_file]"); + }); + + it("truncates long tool output content", () => { + const longContent = "x".repeat(500); + const h: AgentHistoryItem[] = [tool("t1", longContent)]; + const out = renderHistoryAsText(h); + expect(out.length).toBeLessThan(longContent.length + 50); + expect(out).toContain("Tool results:"); + }); +}); + +describe("summarizeHistory", () => { + it("returns same reference when too few turns to summarize", async () => { + const h: AgentHistoryItem[] = [u("q"), a("a")]; + const result = await summarizeHistory(h, { + provider: mockProvider("summary"), + keepRecentTurns: 4, + }); + expect(result).toBe(h); + }); + + it("replaces old turns with summary, keeps recent turns", async () => { + const h: AgentHistoryItem[] = [ + u("turn1"), a("resp1"), + u("turn2"), a("resp2"), + u("turn3"), a("resp3"), + ]; + const result = await summarizeHistory(h, { + provider: mockProvider("The user asked three questions."), + keepRecentTurns: 2, + }); + // summary first + expect(result[0]).toEqual({ + kind: "user_text", + text: expect.stringContaining("[Session context summary:"), + }); + expect((result[0] as { kind: "user_text"; text: string }).text).toContain( + "The user asked three questions.", + ); + // recent turns preserved + expect(result).toContainEqual(u("turn3")); + expect(result).toContainEqual(a("resp3")); + // old turns gone + expect( + result.some((h) => h.kind === "user_text" && (h as { kind: "user_text"; text: string }).text === "turn1"), + ).toBe(false); + }); + + it("calls provider with a system prompt and no tools", async () => { + const calls: Parameters[0][] = []; + const trackingProvider: LLMProvider = { + async complete(p) { + calls.push(p); + return { stopReason: "end_turn", blocks: [{ type: "text", text: "ok" }] }; + }, + }; + const h: AgentHistoryItem[] = [u("a"), a("b"), u("c"), a("d"), u("e"), a("f")]; + await summarizeHistory(h, { provider: trackingProvider, keepRecentTurns: 1 }); + expect(calls).toHaveLength(1); + expect(calls[0]!.tools).toEqual([]); + expect(calls[0]!.system).toMatch(/summarize/i); + }); +}); From 73820d4e279c6f601cfb3748b3d9b7790e64b8ad Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Fri, 17 Apr 2026 14:10:53 +0900 Subject: [PATCH 2/5] feat(session): add summarizeAndCompactHistory with threshold guard --- packages/cli/src/session/context-compact.ts | 33 ++++++- packages/cli/test/context-compact.test.ts | 95 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/context-compact.test.ts diff --git a/packages/cli/src/session/context-compact.ts b/packages/cli/src/session/context-compact.ts index a014b97..1331f30 100644 --- a/packages/cli/src/session/context-compact.ts +++ b/packages/cli/src/session/context-compact.ts @@ -1,6 +1,15 @@ /** Context compaction: intelligently trim conversation history to stay within provider limits. */ -import type { AgentHistoryItem, NormalizedBlock } from "../llm/types.js"; +import type { AgentHistoryItem, LLMProvider, NormalizedBlock } from "../llm/types.js"; +import { summarizeHistory } from "./summarize-history.js"; + +export type SummarizationConfig = { + enabled: boolean; + /** Char count threshold above which summarization fires (default: 120_000) */ + threshold: number; + /** Recent user-led turns to keep unsummarized (default: 4) */ + keepRecentTurns: number; +}; export type CompactionOptions = { /** Maximum total characters in history (default: 200_000) */ @@ -115,3 +124,25 @@ export function compactHistory(history: AgentHistoryItem[], opts: CompactionOpti return result; } + +/** + * Summarize old turns with the LLM when over threshold, then run synchronous + * compaction as a safety net. + */ +export async function summarizeAndCompactHistory( + history: AgentHistoryItem[], + provider: LLMProvider, + summarization: SummarizationConfig, + compactOpts: CompactionOptions = {}, +): Promise { + let current = history; + + if (summarization.enabled && countChars(current) > summarization.threshold) { + current = await summarizeHistory(current, { + provider, + keepRecentTurns: summarization.keepRecentTurns, + }); + } + + return compactHistory(current, compactOpts); +} diff --git a/packages/cli/test/context-compact.test.ts b/packages/cli/test/context-compact.test.ts new file mode 100644 index 0000000..237fd94 --- /dev/null +++ b/packages/cli/test/context-compact.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + summarizeAndCompactHistory, + type SummarizationConfig, +} from "../src/session/context-compact.js"; +import type { AgentHistoryItem, LLMProvider } from "../src/llm/types.js"; + +function u(t: string): AgentHistoryItem { return { kind: "user_text", text: t }; } +function a(t: string): AgentHistoryItem { + return { kind: "assistant", blocks: [{ type: "text", text: t }] }; +} + +function bigHistory(nTurns: number): AgentHistoryItem[] { + const h: AgentHistoryItem[] = []; + for (let i = 0; i < nTurns; i++) { + h.push(u("x".repeat(5_000))); + h.push(a("y".repeat(5_000))); + } + return h; +} + +function trackingProvider(reply = "summary"): { provider: LLMProvider; callCount: () => number } { + let n = 0; + return { + provider: { + async complete() { + n++; + return { stopReason: "end_turn", blocks: [{ type: "text", text: reply }] }; + }, + }, + callCount: () => n, + }; +} + +const DISABLED: SummarizationConfig = { + enabled: false, + threshold: 0, + keepRecentTurns: 4, +}; + +const ENABLED_LOW: SummarizationConfig = { + enabled: true, + threshold: 1_000, + keepRecentTurns: 2, +}; + +describe("summarizeAndCompactHistory", () => { + it("does not call provider when summarization is disabled", async () => { + const { provider, callCount } = trackingProvider(); + const h = bigHistory(5); + await summarizeAndCompactHistory(h, provider, DISABLED); + expect(callCount()).toBe(0); + }); + + it("does not call provider when history is under threshold", async () => { + const { provider, callCount } = trackingProvider(); + const h: AgentHistoryItem[] = [u("hi"), a("hello")]; + await summarizeAndCompactHistory(h, provider, { + enabled: true, + threshold: 999_999, + keepRecentTurns: 4, + }); + expect(callCount()).toBe(0); + }); + + it("calls provider when enabled and over threshold", async () => { + const { provider, callCount } = trackingProvider("concise summary"); + const h = bigHistory(10); + const result = await summarizeAndCompactHistory(h, provider, ENABLED_LOW); + expect(callCount()).toBe(1); + expect(result[0]).toEqual({ + kind: "user_text", + text: expect.stringContaining("[Session context summary:"), + }); + }); + + it("still compacts history after summarization when over maxChars", async () => { + const { provider } = trackingProvider("summary of old stuff"); + const h = bigHistory(20); + const result = await summarizeAndCompactHistory(h, provider, ENABLED_LOW, { + maxChars: 50_000, + }); + let total = 0; + for (const item of result) { + if (item.kind === "user_text") total += item.text.length; + else if (item.kind === "assistant") + total += item.blocks.reduce( + (s, b) => s + (b.type === "text" ? b.text.length : 0), + 0, + ); + } + // allow 10% slack for compaction heuristics + expect(total).toBeLessThanOrEqual(50_000 * 1.1); + }); +}); From d1d62b88734ab36db206ba3f50ca4ee44e66a2de Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Fri, 17 Apr 2026 14:13:24 +0900 Subject: [PATCH 3/5] feat(config): add summarization config key with enabled/threshold/keepRecentTurns --- packages/cli/src/config.ts | 25 +++++++++++++++++ packages/cli/test/config-keys.test.ts | 39 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 9c40b9b..eee0256 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -176,6 +176,16 @@ const RawConfigSchema = z enabled: z.boolean().optional().default(false), }) .optional(), + /** LLM-driven history summarization (replaces old turns before lossy compact). */ + summarization: z + .object({ + enabled: z.boolean().optional().default(false), + /** Char count above which summarization fires (default 120_000). */ + threshold: z.number().int().positive().optional().default(120_000), + /** Recent user-led turns to keep unsummarized (default 4). */ + keepRecentTurns: z.number().int().min(1).max(20).optional().default(4), + }) + .optional(), }) .superRefine((val, ctx) => { if (val.provider === "custom" && !val.baseURL) { @@ -226,6 +236,11 @@ export type InariConfig = { /** Relative or absolute paths to skill pack dirs (skill.yaml + prompt). */ skillPackPaths: string[]; chatTheme: "default" | "soft" | "high_contrast"; + summarization: { + enabled: boolean; + threshold: number; + keepRecentTurns: number; + }; }; export type InariInitConfigFormat = "yaml" | "cjs"; @@ -650,6 +665,11 @@ export function resolveConfigFromRaw(c: RawInariConfig): InariConfig { picker, skillPackPaths: skillPackPathsFromParsed(c), chatTheme: c.chatTheme, + summarization: { + enabled: c.summarization?.enabled ?? false, + threshold: c.summarization?.threshold ?? 120_000, + keepRecentTurns: c.summarization?.keepRecentTurns ?? 4, + }, }; } @@ -688,6 +708,11 @@ export function resolveConfigFromRaw(c: RawInariConfig): InariConfig { picker, skillPackPaths: skillPackPathsFromParsed(c), chatTheme: c.chatTheme, + summarization: { + enabled: c.summarization?.enabled ?? false, + threshold: c.summarization?.threshold ?? 120_000, + keepRecentTurns: c.summarization?.keepRecentTurns ?? 4, + }, }; } diff --git a/packages/cli/test/config-keys.test.ts b/packages/cli/test/config-keys.test.ts index bd4c053..124a154 100644 --- a/packages/cli/test/config-keys.test.ts +++ b/packages/cli/test/config-keys.test.ts @@ -69,6 +69,45 @@ plugins: } }); + it("loads summarization config with explicit values", async () => { + const dir = mkdtempSync(join(tmpdir(), "inari-sum-")); + try { + vi.stubEnv("ANTHROPIC_API_KEY", "sk-test"); + writeFileSync( + join(dir, "inaricode.yaml"), + `provider: anthropic +summarization: + enabled: true + threshold: 90000 + keepRecentTurns: 3 +`, + "utf8", + ); + const cfg = await loadConfig(dir); + expect(cfg.summarization).toEqual({ + enabled: true, + threshold: 90000, + keepRecentTurns: 3, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("summarization defaults to disabled when omitted", async () => { + const dir = mkdtempSync(join(tmpdir(), "inari-sum2-")); + try { + vi.stubEnv("ANTHROPIC_API_KEY", "sk-test"); + writeFileSync(join(dir, "inaricode.yaml"), `provider: anthropic\n`, "utf8"); + const cfg = await loadConfig(dir); + expect(cfg.summarization.enabled).toBe(false); + expect(cfg.summarization.threshold).toBe(120_000); + expect(cfg.summarization.keepRecentTurns).toBe(4); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("resolves egune key when provider is eguna", async () => { const dir = mkdtempSync(join(tmpdir(), "inari-keys-")); try { From ddcea011d7ac757cf668c13af0d133fb480d0769 Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Fri, 17 Apr 2026 14:14:50 +0900 Subject: [PATCH 4/5] feat(agent): wire summarizeAndCompactHistory into agent turn loop --- packages/cli/src/agent/loop.ts | 8 ++++++-- packages/cli/src/ui/chat-repl.ts | 1 + packages/cli/src/ui/chat-tui.tsx | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/agent/loop.ts b/packages/cli/src/agent/loop.ts index bfe40ad..6d99a40 100644 --- a/packages/cli/src/agent/loop.ts +++ b/packages/cli/src/agent/loop.ts @@ -4,7 +4,7 @@ import type { ResolvedShellPolicy } from "../policy/shell.js"; import type { EmbeddingClient } from "../tools/embeddings-api.js"; import { inariJsonLog } from "../observability/json-log.js"; import { buildSystemPrompt } from "./system-prompt.js"; -import { compactHistory } from "../session/context-compact.js"; +import { summarizeAndCompactHistory, type SummarizationConfig } from "../session/context-compact.js"; import { executeTool } from "../utils/concurrency-pool.js"; function truncJson(s: string, max = 2_000): string { @@ -31,6 +31,8 @@ export type AgentTurnOptions = { streaming: boolean; onTextDelta?: (chunk: string) => void; signal?: AbortSignal; + /** LLM-driven context summarization config */ + summarization: SummarizationConfig; }; export type AgentTurnResult = { @@ -60,7 +62,9 @@ export async function runAgentTurn(opts: AgentTurnOptions): Promise setStreaming((s) => s + chunk) : undefined, signal: props.signal, + summarization: props.cfg.summarization, }); setHistory(next); await persist(next); From ac3e93612b2a8c0f3a6e9374c357a78e3d6e688b Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Fri, 17 Apr 2026 14:17:49 +0900 Subject: [PATCH 5/5] feat(ui): add /compact summary slash command for LLM-driven context reduction --- packages/cli/src/i18n/strings.ts | 4 ++ packages/cli/src/ui/chat-repl.ts | 2 + packages/cli/src/ui/chat-slash.ts | 19 +++++- packages/cli/src/ui/chat-tui.tsx | 2 + packages/cli/test/slash-compact.test.ts | 77 +++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/slash-compact.test.ts diff --git a/packages/cli/src/i18n/strings.ts b/packages/cli/src/i18n/strings.ts index 69721f9..110cefc 100644 --- a/packages/cli/src/i18n/strings.ts +++ b/packages/cli/src/i18n/strings.ts @@ -74,6 +74,8 @@ const EN = { slashCompactNoop: "Nothing to compact — already at or below {keep} user turn(s).", slashCompactDone: "Compacted session: {before} → {after} history items (kept last {keep} user turns). Saved if --session.", + slashCompactSummarized: + "History summarized via LLM. Old turns replaced with summary. Saved if --session.", slashUnknown: "Unknown command: {cmd} — try /help", confirmBlock: "\n[confirm: {title}]\n{body}\n", confirmPrompt: "Proceed? [y/N] ", @@ -198,6 +200,8 @@ const MN: Record = { slashCompactNoop: "Нягтруулах зүйл алга — аль хэдийн {keep} эргэлт эсвэл түүнээс бага.", slashCompactDone: "Session нягтруулсан: {before} → {after} түүхийн мөр (--session бол хадгалагдсан). Сүүлийн {keep} хэрэглэгчийн эргэлт.", + slashCompactSummarized: + "Түүх LLM-ээр нягтруулсан. Хуучин эргэлтийг хураангуйгаар солилоо. --session бол хадгалагдсан.", slashUnknown: "Танигдаагүй тушаал: {cmd} — /help үзнэ үү", confirmBlock: "\n[баталгаа: {title}]\n{body}\n", confirmPrompt: "Үргэлжлүүлэх үү? [т/г] (эсвэл y/n) ", diff --git a/packages/cli/src/ui/chat-repl.ts b/packages/cli/src/ui/chat-repl.ts index b918a54..b7ace83 100644 --- a/packages/cli/src/ui/chat-repl.ts +++ b/packages/cli/src/ui/chat-repl.ts @@ -138,6 +138,8 @@ export async function runChatRepl(options: { }, persistEmpty, slashHelpExtra, + provider, + summarization: cfg.summarization, }); if (slash.kind === "exit") break; if (slash.kind === "again") continue; diff --git a/packages/cli/src/ui/chat-slash.ts b/packages/cli/src/ui/chat-slash.ts index 2b27308..5ac3c58 100644 --- a/packages/cli/src/ui/chat-slash.ts +++ b/packages/cli/src/ui/chat-slash.ts @@ -1,6 +1,7 @@ -import type { AgentHistoryItem } from "../llm/types.js"; +import type { AgentHistoryItem, LLMProvider } from "../llm/types.js"; import type { Locale } from "../i18n/locale.js"; import { compactHistoryByUserTurns } from "../session/compact-history.js"; +import { summarizeHistory } from "../session/summarize-history.js"; import { tr } from "../i18n/strings.js"; export type SlashLoopAction = @@ -21,6 +22,9 @@ type SlashCtx = { persistEmpty: () => Promise; /** Appended after built-in /help (e.g. skill pack slash_hints). */ slashHelpExtra?: string; + /** Required for /compact summary. */ + provider: LLMProvider; + summarization: { enabled: boolean; threshold: number; keepRecentTurns: number }; }; /** @@ -71,6 +75,19 @@ export async function handleChatSlashInput(ctx: SlashCtx): Promise 0) { const n = parseInt(rest, 10); diff --git a/packages/cli/src/ui/chat-tui.tsx b/packages/cli/src/ui/chat-tui.tsx index a5bdac6..30e409a 100644 --- a/packages/cli/src/ui/chat-tui.tsx +++ b/packages/cli/src/ui/chat-tui.tsx @@ -151,6 +151,8 @@ function ChatTuiInner( write: (s) => setTranscript((t) => t + s), persistEmpty, slashHelpExtra: props.slashHelpExtra, + provider: props.provider, + summarization: props.cfg.summarization, }); if (slash.kind === "exit") { await persist(history); diff --git a/packages/cli/test/slash-compact.test.ts b/packages/cli/test/slash-compact.test.ts new file mode 100644 index 0000000..d430ce3 --- /dev/null +++ b/packages/cli/test/slash-compact.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { AgentHistoryItem, LLMProvider } from "../src/llm/types.js"; +import { handleChatSlashInput } from "../src/ui/chat-slash.js"; + +function u(t: string): AgentHistoryItem { return { kind: "user_text", text: t }; } +function a(t: string): AgentHistoryItem { + return { kind: "assistant", blocks: [{ type: "text", text: t }] }; +} + +function mockProvider(reply: string): LLMProvider { + return { + async complete() { + return { stopReason: "end_turn", blocks: [{ type: "text", text: reply }] }; + }, + }; +} + +function makeCtx( + trimmed: string, + history: AgentHistoryItem[], + provider: LLMProvider, +) { + const written: string[] = []; + let current = [...history]; + return { + ctx: { + locale: "en" as const, + cwd: "/tmp", + workspaceRoot: "/tmp", + trimmed, + getHistory: () => current, + setHistory: (h: AgentHistoryItem[]) => { current = h; }, + persistHistory: async (_h: AgentHistoryItem[]) => {}, + write: async (s: string) => { written.push(s); }, + persistEmpty: async () => {}, + provider, + summarization: { enabled: true, threshold: 0, keepRecentTurns: 1 }, + }, + written, + getHistory: () => current, + }; +} + +describe("/compact summary", () => { + it("replaces old history with LLM summary and writes confirmation", async () => { + const history = [u("q1"), a("a1"), u("q2"), a("a2"), u("q3"), a("a3")]; + const { ctx, written, getHistory } = makeCtx( + "/compact summary", + history, + mockProvider("Summary of the session."), + ); + const action = await handleChatSlashInput(ctx); + expect(action.kind).toBe("again"); + expect(written.some((s) => s.toLowerCase().includes("summarized"))).toBe(true); + expect( + getHistory().some( + (h) => + h.kind === "user_text" && + (h as { kind: "user_text"; text: string }).text.includes("[Session context summary:"), + ), + ).toBe(true); + }); + + it("does not call provider for numeric /compact", async () => { + let providerCalled = false; + const provider: LLMProvider = { + async complete() { + providerCalled = true; + return { stopReason: "end_turn", blocks: [{ type: "text", text: "x" }] }; + }, + }; + const history = [u("q1"), a("a1"), u("q2"), a("a2"), u("q3"), a("a3"), u("q4"), a("a4"), u("q5"), a("a5")]; + const { ctx } = makeCtx("/compact 2", history, provider); + await handleChatSlashInput(ctx); + expect(providerCalled).toBe(false); + }); +});