diff --git a/README-en.md b/README-en.md index 8a18ed04..ab386deb 100644 --- a/README-en.md +++ b/README-en.md @@ -77,6 +77,7 @@ Skills are discovered from these locations, in priority order: | `/resume` | Choose a previous conversation to continue | | `/fork` | Fork the current conversation | | `/continue` | Continue the active conversation or pick one to resume | +| `/compact` | Manually compact the current conversation context | | `/model` | Switch model, thinking mode, and reasoning effort | | `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) | | `/init` | Initialize an AGENTS.md file (LLM project instructions) | diff --git a/README-zh_CN.md b/README-zh_CN.md index 625a5f3b..1c27f486 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -76,6 +76,7 @@ Skills 会按以下优先级扫描: | `/resume` | 选择历史对话继续 | | `/fork` | 从当前对话创建独立的新会话 | | `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/compact` | 手动压缩当前对话上下文 | | `/model` | 切换模型、思考模式和推理强度 | | `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | | `/init` | 初始化 AGENTS.md 文件 | diff --git a/README.md b/README.md index 840e7071..05ee548e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Skills 会按以下优先级扫描: | `/resume` | 选择历史对话继续 | | `/fork` | 从当前对话创建独立的新会话 | | `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/compact` | 手动压缩当前对话上下文 | | `/model` | 切换模型、思考模式和推理强度 | | `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | | `/init` | 初始化 AGENTS.md 文件 | diff --git a/packages/cli/src/tests/compact-command.test.ts b/packages/cli/src/tests/compact-command.test.ts new file mode 100644 index 00000000..8ad85c01 --- /dev/null +++ b/packages/cli/src/tests/compact-command.test.ts @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { syncBuiltinESMExports } from "node:module"; +import { PassThrough, Writable } from "node:stream"; +import { test, type TestContext } from "node:test"; +import { setTimeout } from "node:timers/promises"; +import { stripVTControlCharacters } from "node:util"; +import React from "react"; +import { render } from "ink"; +import { SessionManager, type SessionEntry, type SessionMessage } from "@vegamo/deepcode-core"; +import App from "../ui/views/App"; + +async function harness(t: TestContext, compact: (signal: AbortSignal) => Promise, active = true) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-compact-command-")); + const originalEnv = process.env; + process.env = Object.fromEntries(Object.entries(originalEnv).filter(([key]) => !key.startsWith("DEEPCODE_"))); + t.mock.method(os, "homedir", () => root); + syncBuiltinESMExports(); + fs.mkdirSync(path.join(root, ".deepcode")); + fs.writeFileSync(path.join(root, ".deepcode", "settings.json"), JSON.stringify({ statusline: { enabled: false } })); + const entry: SessionEntry = { + id: "compact-test", + summary: "Previous conversation", + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage: null, + usagePerModel: null, + activeTokens: 20000, + processes: null, + createTime: "2026-01-01T00:00:00Z", + updateTime: "2026-01-01T00:00:00Z", + }; + const history: SessionMessage[] = [ + { + id: "old-prompt", + sessionId: entry.id, + role: "user", + content: "Keep this conversation visible", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: entry.createTime, + updateTime: entry.updateTime, + }, + ]; + t.mock.method(SessionManager.prototype, "initMcpServers", async () => {}); + t.mock.method(SessionManager.prototype, "listSkills", async () => []); + t.mock.method(SessionManager.prototype, "getActiveSessionId", () => (active ? entry.id : null)); + t.mock.method(SessionManager.prototype, "getSession", () => (active ? entry : null)); + const list = t.mock.method(SessionManager.prototype, "listSessions", () => (active ? [entry] : [])); + t.mock.method(SessionManager.prototype, "listSessionMessages", () => history); + const prompt = t.mock.method(SessionManager.prototype, "handleUserPrompt", async () => {}); + const interrupt = t.mock.method(SessionManager.prototype, "interruptActiveSession", () => {}); + const compaction = t.mock.method( + SessionManager.prototype as unknown as { + compactSessionOnce(sessionId: string, signal: AbortSignal, retain: number): Promise; + }, + "compactSessionOnce", + async (id: string, signal?: AbortSignal) => { + assert.equal(id, entry.id); + assert.ok(signal); + const changed = await compact(signal); + if (changed) entry.activeTokens = 1000; + return changed; + } + ); + const frames: string[] = []; + const output = Object.assign( + new Writable({ + write(chunk, _encoding, callback) { + const frame = stripVTControlCharacters(chunk.toString()); + if (frame.trim()) frames.push(frame); + callback(); + }, + }), + { columns: 120, rows: 40, isTTY: true } + ); + const input = Object.assign(new PassThrough(), { isTTY: true, setRawMode() {}, ref() {}, unref() {} }); + const app = render( + React.createElement(App, { + projectRoot: root, + resumeSessionId: active ? entry.id : undefined, + }), + { + stdin: input as unknown as NodeJS.ReadStream, + stdout: output as unknown as NodeJS.WriteStream, + stderr: output as unknown as NodeJS.WriteStream, + debug: true, + patchConsole: false, + exitOnCtrlC: false, + } + ); + t.after(() => { + app.unmount(); + app.cleanup(); + t.mock.restoreAll(); + syncBuiltinESMExports(); + process.env = originalEnv; + fs.rmSync(root, { recursive: true, force: true }); + }); + async function flush() { + await setTimeout(30); + await app.waitUntilRenderFlush(); + } + async function send(value: string) { + input.emit("data", value); + await flush(); + } + await flush(); + return { + app, + frames, + entry, + list, + prompt, + interrupt, + compaction, + send, + flush, + async submit(value = "/compact") { + await send(value); + await send("\r"); + }, + latest: () => frames.at(-1) ?? "", + }; +} + +test("/compact preserves displayed history, refreshes usage and waits for another prompt", async (t) => { + const h = await harness(t, async () => true); + const refreshes = h.list.mock.callCount(); + await h.submit(); + assert.equal(h.compaction.mock.callCount(), 1); + assert.equal(h.prompt.mock.callCount(), 0); + assert.match(h.latest(), /> Conversation compacted\./); + assert.equal(h.latest().match(/Conversation compacted\./g)?.length, 1); + assert.ok(h.frames.some((frame) => frame.includes("Compacting conversation..."))); + assert.match(h.latest(), /1000\//); + assert.ok(h.frames.some((frame) => frame.includes("Keep this conversation visible"))); + assert.ok(h.list.mock.callCount() > refreshes); + await h.submit("Continue working"); + assert.equal(h.prompt.mock.callCount(), 1); + assert.match(h.latest(), /> Conversation compacted\./); +}); + +test("/compact reports no active conversation without starting a request", async (t) => { + const h = await harness(t, async () => true, false); + await h.submit(); + assert.match(h.latest(), /No active conversation to compact\./); + assert.equal(h.compaction.mock.callCount(), 0); + assert.equal(h.prompt.mock.callCount(), 0); +}); + +test("/compact can be selected from the completion menu", async (t) => { + const h = await harness(t, async () => true); + await h.submit("/comp"); + assert.match(h.latest(), /Conversation compacted\./); + assert.equal(h.compaction.mock.callCount(), 1); + assert.equal(h.prompt.mock.callCount(), 0); +}); + +test("/compact reports a no-op without claiming success", async (t) => { + const h = await harness(t, async () => false); + // Trailing text follows the existing behavior of parameterless commands. + await h.submit("/compact extra text"); + assert.match(h.latest(), /Conversation was not compacted\./); + assert.equal(h.compaction.mock.callCount(), 1); + assert.equal(h.prompt.mock.callCount(), 0); +}); + +test("/compact failures restore input and allow retry", async (t) => { + let attempts = 0; + const h = await harness(t, async () => { + if (++attempts === 1) throw new Error("Summary request failed"); + return true; + }); + await h.submit(); + assert.match(h.latest(), /Error: Summary request failed/); + await h.submit(); + assert.match(h.latest(), /Conversation compacted\./); + assert.doesNotMatch(h.latest(), /Summary request failed/); + assert.equal(h.prompt.mock.callCount(), 0); +}); + +test("Esc cancels compaction without interrupting the session; duplicate submissions are blocked", async (t) => { + let signal: AbortSignal | undefined; + const h = await harness(t, (current) => { + signal = current; + return new Promise((_resolve, reject) => { + current.addEventListener("abort", () => reject(current.reason), { once: true }); + }); + }); + await h.submit(); + assert.match(h.latest(), /Compacting conversation\.\.\./); + await h.submit(); + assert.equal(h.compaction.mock.callCount(), 1); + assert.equal(h.prompt.mock.callCount(), 0); + await h.send("\u001b"); + assert.equal(signal?.aborted, true); + assert.equal(h.interrupt.mock.callCount(), 0); + assert.match(h.latest(), /Compaction cancelled\./); + assert.doesNotMatch(h.latest(), /Error:/); + // Clear the duplicate command left in the input before submitting a normal prompt. + await h.send("\u0015"); + await h.submit("Continue working"); + assert.equal(h.prompt.mock.callCount(), 1); +}); + +test("unmount cancels an in-flight compaction", async (t) => { + let signal: AbortSignal | undefined; + const h = await harness(t, (current) => { + signal = current; + return new Promise((_resolve, reject) => { + current.addEventListener("abort", () => reject(current.reason), { once: true }); + }); + }); + await h.submit(); + h.app.unmount(); + await h.flush(); + assert.equal(signal?.aborted, true); + assert.equal(h.interrupt.mock.callCount(), 0); +}); + +test("exiting cancels an in-flight compaction", async (t) => { + let signal: AbortSignal | undefined; + const h = await harness(t, (current) => { + signal = current; + return new Promise((_resolve, reject) => { + current.addEventListener("abort", () => reject(current.reason), { once: true }); + }); + }); + await h.submit(); + await h.send("\u0004"); + await h.send("\u0004"); + assert.equal(signal?.aborted, true); + assert.equal(h.interrupt.mock.callCount(), 0); + assert.doesNotMatch(h.latest(), /Compaction cancelled\.|Error:/); +}); diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts index a5923be8..7df38519 100644 --- a/packages/cli/src/tests/slash-commands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -28,6 +28,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => { "resume", "fork", "continue", + "compact", "undo", "mcp", "raw", @@ -130,6 +131,14 @@ test("findExactSlashCommand returns built-in /undo", () => { assert.equal(item?.kind, "undo"); }); +test("/compact is suggested and the built-in takes precedence over a skill with the same name", () => { + const items = buildSlashCommands([{ name: "compact", path: "/skills/compact/SKILL.md", description: "A skill" }]); + assert.ok(filterSlashCommands(items, "/comp").some((item) => item.kind === "compact")); + const command = findExactSlashCommand(items, "/compact"); + assert.equal(command?.kind, "compact"); + assert.equal(command?.description, "Compact the current conversation"); +}); + test("findExactSlashCommand returns built-in /skills", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/skills"); diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index 0ffdcdfc..a7c3d65f 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -10,6 +10,7 @@ export type SlashCommandKind = | "resume" | "fork" | "continue" + | "compact" | "undo" | "mcp" | "raw" @@ -73,6 +74,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/continue", description: "Continue the active conversation or pick one to resume", }, + { + kind: "compact", + name: "compact", + label: "/compact", + description: "Compact the current conversation", + }, { kind: "undo", name: "undo", diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index efa0fa36..2c96e800 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -82,8 +82,11 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes const writeRef = useRef(write); const lastRenderedColumnsRef = useRef(null); const messagesRef = useRef([]); + const compactionControllerRef = useRef(null); const [view, setView] = useState("chat"); const [busy, setBusy] = useState(false); + const [compacting, setCompacting] = useState(false); + const [commandNotice, setCommandNotice] = useState(null); const [skills, setSkills] = useState([]); const [messages, setMessages] = useState([]); const [sessions, setSessions] = useState([]); @@ -233,6 +236,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes sessionManager.setActiveSessionId(null); setStatusLine(""); setErrorLine(null); + setCommandNotice(null); setRunningProcesses(null); setActiveStatus(null); setActiveAskPermissions(undefined); @@ -276,6 +280,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes */ useEffect(() => { return () => { + compactionControllerRef.current?.abort(); + compactionControllerRef.current = null; sessionManager.dispose(); }; }, [sessionManager]); @@ -283,6 +289,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes writeRef.current = write; const handleExit = useCallback( ({ showCommand, showSummary }: { showCommand: boolean; showSummary: boolean }) => { + compactionControllerRef.current?.abort(); + compactionControllerRef.current = null; setIsExiting(true); setTimeout(() => { const activeSessionId = sessionManager.getActiveSessionId(); @@ -321,6 +329,47 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes handleExit({ showCommand: true, showSummary: true }); return; } + if (compactionControllerRef.current) return; + setCommandNotice(null); + if (submission.command === "compact") { + if (busy) return; + setErrorLine(null); + const sessionId = sessionManager.getActiveSessionId(); + if (!sessionId || !sessionManager.getSession(sessionId)) { + setCommandNotice("No active conversation to compact."); + return; + } + const controller = new AbortController(); + compactionControllerRef.current = controller; + setBusy(true); + setCompacting(true); + setStreamProgress(null); + setRetryEvent(null); + try { + const changed = await sessionManager.compactSession(sessionId, controller.signal); + if (compactionControllerRef.current !== controller) return; + const session = sessionManager.getSession(sessionId); + setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : ""); + refreshSessionsList(); + if (!changed) setCommandNotice("Conversation was not compacted."); + } catch (error) { + if (compactionControllerRef.current !== controller) return; + if (controller.signal.aborted) { + setCommandNotice("Compaction cancelled."); + } else { + setErrorLine(error instanceof Error ? error.message : String(error)); + } + } finally { + if (compactionControllerRef.current === controller) { + compactionControllerRef.current = null; + setCompacting(false); + setBusy(false); + setStreamProgress(null); + setRetryEvent(null); + } + } + return; + } if (submission.command === "new") { if (onRestart) { onRestart(); @@ -454,6 +503,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes } }, [ + busy, sessionManager, pendingPermissionReply, handleExit, @@ -469,6 +519,10 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes ); const handleInterrupt = useCallback(() => { + if (compactionControllerRef.current) { + compactionControllerRef.current.abort(); + return; + } sessionManager.interruptActiveSession(); }, [sessionManager]); @@ -580,6 +634,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes const handleSelectSession = useCallback( async (sessionId: string) => { + setCommandNotice(null); sessionManager.setActiveSessionId(sessionId); // Clear first so resets its index to 0. await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); @@ -843,17 +898,19 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes const shouldShowQuestionPrompt = Boolean(pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId)); const loadingText = useMemo( () => - busy - ? buildLoadingText({ - progress: streamProgress, - retry: retryEvent, - processes: runningProcesses, - screenWidth, - now: Date.now(), - }) - : null, + compacting + ? "Compacting conversation..." + : busy + ? buildLoadingText({ + progress: streamProgress, + retry: retryEvent, + processes: runningProcesses, + screenWidth, + now: Date.now(), + }) + : null, // eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation - [busy, streamProgress, retryEvent, runningProcesses, nowTick, screenWidth] + [compacting, busy, streamProgress, retryEvent, runningProcesses, nowTick, screenWidth] ); const welcomeItem: SessionMessage = useMemo( @@ -887,6 +944,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes busy ? "busy" : "idle", statusLine, errorLine ?? "", + commandNotice ?? "", showProcessStdout ? "stdout" : "main", activeStatus ?? "", staticItems.length, @@ -900,6 +958,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes activeAskPermissions, activeStatus, busy, + commandNotice, errorLine, pendingPermissionReply, pendingQuestion, @@ -994,6 +1053,11 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes }} {(busy || statusLine) && !isExiting ? : null} + {commandNotice && !isExiting ? ( + + {commandNotice} + + ) : null} {errorLine ? ( Error: {errorLine} diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 3e997771..00b87ad8 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -74,7 +74,7 @@ export type PromptSubmission = { alwaysAllows?: PermissionScope[]; planMode?: boolean; isAnswers?: boolean; - command?: "new" | "resume" | "fork" | "continue" | "undo" | "mcp" | "exit"; + command?: "new" | "resume" | "fork" | "continue" | "compact" | "undo" | "mcp" | "exit"; }; export type PromptDraft = { @@ -730,6 +730,11 @@ export const PromptInput = React.memo(function PromptInput({ resetPromptInput(); return; } + if (item.kind === "compact") { + onSubmit({ text: "", imageUrls: [], command: "compact" }); + resetPromptInput(); + return; + } if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); resetPromptInput(); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 77b550e6..07804391 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -2086,8 +2086,15 @@ ${agentInstructions} } } - async compactSession(sessionId: string, signal?: AbortSignal): Promise { - await this.compactSessionOnce(sessionId, signal ?? new AbortController().signal, 0); + async compactSession(sessionId: string, signal?: AbortSignal): Promise { + const compactionSignal = signal ?? new AbortController().signal; + compactionSignal.throwIfAborted(); + this.onAssistantMessage(this.buildAssistantMessage(sessionId, "Compacting conversation...", null), false); + const changed = await this.compactSessionOnce(sessionId, compactionSignal, 0); + if (changed) { + this.onAssistantMessage(this.buildUserMessage(sessionId, { text: "Conversation compacted." }), false); + } + return changed; } /** Commit only a complete, smaller checkpoint against an unchanged selected history. */ diff --git a/packages/core/src/tests/compaction.test.ts b/packages/core/src/tests/compaction.test.ts index f7155a05..2febd651 100644 --- a/packages/core/src/tests/compaction.test.ts +++ b/packages/core/src/tests/compaction.test.ts @@ -55,6 +55,7 @@ function fixture(history: SessionMessage[], respond: (request: any, call: number process.env.HOME = root; process.env.USERPROFILE = root; const requests: any[] = []; + const notices: SessionMessage[] = []; const settings = { model: "test-vision", contextWindow: 256 * 1024, @@ -79,7 +80,7 @@ function fixture(history: SessionMessage[], respond: (request: any, call: number }), getResolvedSettings: () => settings, renderMarkdown: (text) => text, - onAssistantMessage: () => {}, + onAssistantMessage: (message) => notices.push(message), }); const internal = manager as any; internal.saveSessionMessages("compact-test", history); @@ -87,6 +88,7 @@ function fixture(history: SessionMessage[], respond: (request: any, call: number manager, internal, requests, + notices, settings, root, read: () => manager.listSessionMessages("compact-test"), @@ -100,6 +102,39 @@ function compactPrefixes(history: SessionMessage[], prices: number[], retain: nu return selection.ends.map((end) => selection.history.slice(0, end)); } +test("manual compaction reports whether it changed history", async () => { + const empty = fixture([]); + assert.equal(await empty.compact(), false); + assert.equal(empty.requests.length, 0); + assert.deepEqual( + empty.notices.map((item) => item.content), + ["Compacting conversation..."] + ); + const short = fixture([message("only")]); + assert.equal(await short.compact(), false); + assert.equal(short.requests.length, 0); + const history = [message("older"), message("reply", "assistant"), message("tail")]; + const ready = fixture(history); + assert.equal(await ready.compact(), true); + assert.equal(ready.requests.length, 1); + assert.deepEqual( + ready.notices.map((item) => [item.role, item.content]), + [ + ["assistant", "Compacting conversation..."], + ["user", "Conversation compacted."], + ] + ); + assert.ok(ready.notices.every((notice) => !ready.read().some((item) => item.id === notice.id))); + assert.deepEqual( + ready + .read() + .filter((item) => item.visible) + .map((item) => item.id), + history.map((item) => item.id) + ); + assert.equal(ready.read().find((item) => item.meta?.isSummary)?.visible, false); +}); + test("request estimates include reasoning and tools without charging image base64 as text", () => { const request = (data: string) => [ { diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md index a0cd5e50..c0abd68b 100644 --- a/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md @@ -78,6 +78,7 @@ Skills 会按以下优先级扫描: | `/new` | 开始新对话 | | `/resume` | 选择历史对话继续 | | `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/compact` | 手动压缩当前对话上下文 | | `/model` | 切换模型、思考模式和推理强度 | | `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | | `/init` | 初始化 AGENTS.md 文件 |