diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 49b37dc4ef..c9ae31f4c8 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -6,6 +6,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent-preset-registry' @@ -14,6 +15,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, @@ -195,6 +197,122 @@ describe('minimal agent preset', () => { }) }) +describe('minimal manual compaction', () => { + const snapshotDir = fileURLToPath(new URL('../../../snapshots/web/minimal-compact', import.meta.url)) + let scaffold: WebScaffold + let handle: AgentHandle + let browser: Browser | undefined + let page: Page | undefined + const requests: GenerateOptions[] = [] + + beforeAll(async () => { + const fixture = join(snapshotDir, 'session.v4.jsonl') + const prompts = fixtureUserPrompts(await readFile(fixture, 'utf8')) + expect(prompts).toHaveLength(2) + const [initial, continuation] = prompts + if (initial === undefined || continuation === undefined) throw new Error('minimal compaction fixture requires two user prompts') + scaffold = await launchWebScaffold({ replayFixture: fixture, compareReplaySession: true }) + scaffold.ctx.on('llm/stream', async function* (options, next) { + requests.push(options) + yield* next() + }) + handle = await scaffold.ctx.agents.create({ + sessionId: SessionId('minimal-compact-smoke'), + meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: initial }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + const session = handle.agent.session + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) + const generation = session.surface.replaceGeneration + const execution = await scaffold.ctx.commands.execute(handle.agent, '/compact', [], new AbortController().signal) + expect(execution?.result.kind).toBe('success') + expect(session.surface.replaceGeneration).toBe(generation + 1) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: continuation }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + }) + + afterAll(async () => { + const failures: unknown[] = [] + await page?.close().catch((error: unknown) => failures.push(error)) + await browser?.close().catch((error: unknown) => failures.push(error)) + await handle?.dispose().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'minimal compaction teardown failed') + }) + + it('records the manual command, summary, and durable history replacement', () => { + const session = handle.agent.session + const events = session.snapshotEvents() + const command = events.find(event => event.type === 'command/run') + const start = events.find(event => event.type === 'compaction/start') + const summary = events.find(event => event.type === 'compaction/summary') + const end = events.find(event => event.type === 'compaction/end') + const done = events.find(event => event.type === 'command/done') + expect(command?.data.name).toBe('compact') + expect(start?.data).toMatchObject({ turn: null, sourceCommandId: command?.data.commandId }) + expect(summary?.data).toMatchObject({ sourceCommandId: command?.data.commandId, llmStreamCall: true }) + expect(end?.data).toEqual(start?.data) + expect(done?.data).toMatchObject({ kind: 'success', sourceEventSeq: summary?.seq }) + const checkpoint = events.find(event => event.type === 'user/message' && event.data.source.kind === 'compact-checkpoint') + if (checkpoint?.type !== 'user/message') throw new Error('manual compaction produced no checkpoint') + expect(checkpoint.surfaceOp).toMatchObject({ + op: 'replace', startSeq: summary?.data.shadowedRange.start, endSeq: summary?.data.shadowedRange.end, + }) + expect(session.deriveMessages().some(message => message.id === checkpoint.data.id)).toBe(true) + const original = events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') + if (original?.type !== 'user/message') throw new Error('minimal compaction has no original user message') + expect(session.deriveMessages().some(message => message.id === original.data.id)).toBe(false) + }) + + it('continues with the summary and the same minimal prompt and tools', () => { + expect(requests.map(request => request.purpose)).toEqual([undefined, 'compaction', undefined]) + const summary = handle.agent.session.snapshotEvents().find(event => event.type === 'compaction/summary') + if (summary === undefined) throw new Error('minimal compaction produced no summary') + const [initial, , continuation] = requests + if (initial === undefined || continuation === undefined) throw new Error('minimal compaction did not continue') + expect(continuation.messages.flatMap(message => message.content)).toEqual(expect.arrayContaining(summary.data.summary)) + expect(continuation.messages[0]).toEqual(initial.messages[0]) + expect(continuation.tools).toEqual(initial.tools) + expect(continuation.messages.flatMap(message => message.content) + .some(block => block.type === 'text' && block.text === 'MINIMAL_COMPACT_READY')).toBe(true) + expect(handle.agent.session.deriveMessages().at(-1)?.content) + .toEqual([{ type: 'text', text: 'MINIMAL_COMPACT_OK' }]) + }) + + it.skipIf(MODE === 'record')('renders the manual compaction card and its summary', async () => { + onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-minimal-compact') }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByText('MINIMAL_COMPACT_OK', { exact: true }).waitFor() + const card = page.getByRole('button', { name: /^compact Compacted / }) + await card.click() + await expect.poll(() => card.getAttribute('aria-expanded')).toBe('true') + await compareOrRefreshGolden(join(snapshotDir, 'ui.expected.md'), + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(snapshotDir, [ + 'session.v4.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', + ]) + }) +}) + describe('minimal image reading', () => { const snapshotDir = fileURLToPath(new URL('../../../snapshots/web/minimal-read-image', import.meta.url)) const prompt = 'Use read_image to inspect red.png. Then reply exactly MINIMAL_IMAGE_OK and stop.' diff --git a/snapshots/web/minimal-compact/session.v4.jsonl b/snapshots/web/minimal-compact/session.v4.jsonl new file mode 100644 index 0000000000..3168ee012d --- /dev/null +++ b/snapshots/web/minimal-compact/session.v4.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1788240634286,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"minimal"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Remember the project requirements: implement image reading in minimal mode by reusing the existing filesystem tool. Preserve the fixed system prompt and keep standard mode unchanged. The new tool selection configuration must default to the full filesystem suite and reject unknown names, duplicate names, and empty lists. Minimal mode selects read_image only. Image reads must use the inherited filesystem authorization and durable attachment store, and the calling model must explicitly support image input. Do not register text read, write, or edit tools in minimal mode. Enable automatic compaction under context pressure and manual /compact while the agent is idle, using the existing backend to generate the summary. Keep the token meter shared and the compaction service isolated per preset. Preserve balanced tool-call and result pairs when replacing older history, retain recent context, and continue from the checkpoint. Dispose every tool, command, and listener with its owning plugin. Reply exactly MINIMAL_COMPACT_READY and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Remember the project requirements: implement image reading in minimal mode by reusing the existing filesystem tool. Preserve the fixed system prompt and keep standard mode unchanged. The new tool selection configuration must default to the full filesystem suite and reject unknown names, duplicate names, and empty lists. Minimal mode selects read_image only. Image reads must use the inherited filesystem authorization and durable attachment store, and the calling model must explicitly support image input. Do not register text read, write, or edit tools in minimal mode. Enable automatic compaction under context pressure and manual /compact while the agent is idle, using the existing backend to generate the summary. Keep the token meter shared and the compaction service isolated per preset. Preserve balanced tool-call and result pairs when replacing older history, retain recent context, and continue from the checkpoint. Dispose every tool, command, and listener with its owning plugin. Reply exactly MINIMAL_COMPACT_READY and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\"."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"session/title","data":{"title":"Remember the project requirements: imple","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"MINIMAL_COMPACT_READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":4},"stream":[{"type":"chunk","time":1791729497737,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791729497737,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"MINIMAL_COMPACT_READY"}}},{"type":"chunk","time":1791729497737,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}},{"type":"chunk","time":1791729497737,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"command/run","data":{"commandId":"{{command:1}}","name":"compact","args":"","source":{"kind":"user"}}} +{"type":"compaction/start","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","turn":null}} +{"type":"compaction/summary","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","summary":[{"type":"text","text":"Implement minimal image reading through the existing filesystem tool, preserving permissions, model capability checks, and standard mode. Enable automatic and manual compaction."}],"rawOutput":[{"type":"text","text":"Implement minimal image reading through the existing filesystem tool, preserving permissions, model capability checks, and standard mode. Enable automatic and manual compaction."}],"llmStreamCall":true,"shadowedRange":{"start":8,"end":9},"shadowedSeqs":[8,9],"shadowedTokenCount":326,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":65536}} +{"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"Implement minimal image reading through the existing filesystem tool, preserving permissions, model capability checks, and standard mode. Enable automatic and manual compaction."},{"type":"text","text":""}],"source":{"kind":"compact-checkpoint","compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}"},"role":"user","id":"{{message:5}}"},"sourceEventSeqs":[17,18,8,9],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":9}} +{"type":"compaction/end","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","turn":null}} +{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Compacted 2 history items (~326 tokens).","sourceEventSeq":18}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue using the project requirements. Reply exactly MINIMAL_COMPACT_OK and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Continue using the project requirements. Reply exactly MINIMAL_COMPACT_OK and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\"."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."}]},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"MINIMAL_COMPACT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":4},"stream":[{"type":"chunk","time":1791729497769,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791729497769,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"MINIMAL_COMPACT_OK"}}},{"type":"chunk","time":1791729497769,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}},{"type":"chunk","time":1791729497769,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/minimal-compact/snapshot.yml b/snapshots/web/minimal-compact/snapshot.yml new file mode 100644 index 0000000000..edaa3da4a8 --- /dev/null +++ b/snapshots/web/minimal-compact/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: minimal-compact +profile: web +composition: web-minimal-compact +recording: authored +header: + class: web-minimal-compact + pin: true + changes: 1 diff --git a/snapshots/web/minimal-compact/system-prompt.expected.md b/snapshots/web/minimal-compact/system-prompt.expected.md new file mode 100644 index 0000000000..038dd17f73 --- /dev/null +++ b/snapshots/web/minimal-compact/system-prompt.expected.md @@ -0,0 +1 @@ +You are a helpful software engineer assistant. diff --git a/snapshots/web/minimal-compact/tool-schemas.expected.json b/snapshots/web/minimal-compact/tool-schemas.expected.json new file mode 100644 index 0000000000..5ed83c5ec3 --- /dev/null +++ b/snapshots/web/minimal-compact/tool-schemas.expected.json @@ -0,0 +1,98 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "working_directory", + "description": "Read the current working directory, or change it with cd. Relative paths use the current directory. Existing shells and running processes keep their own directories.", + "parameters": { + "type": "object", + "properties": { + "cd": { + "type": "string", + "description": "Existing directory to enter. Omit to read the current directory." + } + } + } + } + ], + "changes": [ + [ + { + "name": "bash", + "description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "working_directory", + "description": "Read the current working directory, or change it with cd. Relative paths use the current directory. Existing shells and running processes keep their own directories.", + "parameters": { + "type": "object", + "properties": { + "cd": { + "type": "string", + "description": "Existing directory to enter. Omit to read the current directory." + } + } + } + } + ] + ] +} diff --git a/snapshots/web/minimal-compact/ui.expected.md b/snapshots/web/minimal-compact/ui.expected.md new file mode 100644 index 0000000000..5c05262f1e --- /dev/null +++ b/snapshots/web/minimal-compact/ui.expected.md @@ -0,0 +1,41 @@ +- banner: + - navigation "Session hierarchy": "Remember the project requirements: imple" + - text: Minimal mode + - button "More actions" + - button "Open right sidebar" + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" +- text: "Remember the project requirements: implement image reading in minimal mode by reusing the existing filesystem tool. Preserve the fixed system prompt and keep standard mode unchanged. The new tool selection configuration must default to the full filesystem suite and reject unknown names, duplicate names, and empty lists. Minimal mode selects read_image only. Image reads must use the inherited filesystem authorization and durable attachment store, and the calling model must explicitly support image input. Do not register text read, write, or edit tools in minimal mode. Enable automatic compaction under context pressure and manual /compact while the agent is idle, using the existing backend to generate the summary. Keep the token meter shared and the compaction service isolated per preset. Preserve balanced tool-call and result pairs when replacing older history, retain recent context, and continue from the checkpoint. Dispose every tool, command, and listener with its owning plugin. Reply exactly MINIMAL_COMPACT_READY and stop. {{clock}}" +- button "Copy" +- status: Completed +- button "Completed in {{duration}}" [disabled] +- paragraph: MINIMAL_COMPACT_READY +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- text: {{clock}} +- button "compact Compacted 2 history items (~{{tokens}} tokens)" [expanded] +- paragraph: Implement minimal image reading through the existing filesystem tool, preserving permissions, model capability checks, and standard mode. Enable automatic and manual compaction. +- text: Continue using the project requirements. Reply exactly MINIMAL_COMPACT_OK and stop. {{clock}} +- button "Copy" +- status: Completed +- button "Completed in {{duration}}" [disabled] +- paragraph: MINIMAL_COMPACT_OK +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- text: {{clock}} +- textbox "Message or run a task, / commands, @ files or sessions" +- button "Add files or run commands" +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash +- button "Send message" [disabled] +- button "2 turns 2 steps" +- button "28 tok ยท Cache hit 0%": 28 tokCache hit 0% +- button "0% of context used": 0%