('[role="treeitem"]')
+ if (group === null) throw new Error('fixture Workspace group missing')
+ if (group.getAttribute('aria-expanded') === 'false') {
+ fireEvent.click(within(group).getByText('fixture'))
+ await waitFor(() => {
+ expect(within(tree).getByText('4 sessions').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe('true')
+ })
+ }
+ const session = await within(tree).findByText('Fixture 历史会话')
+ fireEvent.click(session)
+ await waitFor(() => {
+ expect(document.querySelector('[data-variant="code"]')).not.toBeNull()
+ }, { timeout: 10_000 })
+}
+
+it('renders the fixture run_code turn: code parent row, nested sub-rows, error state', async () => {
+ boot()
+ await openFixtureSession()
+
+ const codeRoot = document.querySelector('[data-variant="code"]')
+ if (codeRoot === null) throw new Error('code-variant row missing')
+ const nest = codeRoot.closest('[class*="callRow"]')?.querySelector('[data-subcalls]')
+ if (nest === undefined || nest === null) throw new Error('sub-call nest missing under the code row')
+
+ expect({
+ parentRow: visibleText(codeRoot),
+ // The three sub-rows in dispatch order: bash rides the sample plugin's
+ // keyed registration (the same one a native top-level bash row uses),
+ // both reads ride GenericToolCard.
+ bashSample: nest.querySelector('[data-sample="bash-global"]') !== null,
+ subRows: [...nest.querySelectorAll(':scope > *')].map(visibleText),
+ errorSubRow: nest.querySelector('[data-state="error"]') !== null,
+ }).toMatchInlineSnapshot(`
+ {
+ "bashSample": true,
+ "errorSubRow": true,
+ "parentRow": "CodeRead the notes files and summarize",
+ "subRows": [
+ "BashList notes",
+ "Readnotes/demo.txt",
+ "Readnotes/missing.txt",
+ ],
+ }
+ `)
+})
+
+it('expands the code row into the program body and resolves a sub-row through the details panel', async () => {
+ boot()
+ await openFixtureSession()
+
+ // Expand: the leading control reveals the program (shiki-tokenized: the
+ // text splits into styled spans inside one tree).
+ const codeRoot = document.querySelector('[data-variant="code"]')
+ if (codeRoot === null) throw new Error('code-variant row missing')
+ const toggle = codeRoot.querySelector('button[aria-expanded]')
+ if (toggle === null) throw new Error('code row expand control missing')
+ fireEvent.click(toggle)
+ await waitFor(() => {
+ // Scope to THIS row: the markdown fixture turn also renders shiki pres.
+ const pre = codeRoot.querySelector('pre.shiki')
+ if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
+ throw new Error('highlighted program body missing under the code row')
+ }
+ })
+
+ // Sub-row click → details panel resolves the sub-callId with FULL output.
+ const nest = document.querySelector('[data-subcalls]')
+ if (nest === null) throw new Error('sub-call nest missing')
+ const bashRow = nest.querySelector('[data-sample="bash-global"]')
+ if (bashRow === null) throw new Error('bash sample sub-row missing')
+ fireEvent.click(bashRow)
+ const details = await screen.findByText('Input')
+ const panel = details.closest('[class*="root"]')
+ if (panel === null) throw new Error('details panel missing')
+ expect({
+ title: visibleText(within(panel as HTMLElement).getByText('bash')),
+ inputEchoesArgs: visibleText(panel).includes('ls notes'),
+ outputComplete: visibleText(panel).includes('demo.txt new-demo.txt')
+ || visibleText(panel).includes('demo.txt\nnew-demo.txt')
+ || (panel.textContent ?? '').includes('demo.txt\nnew-demo.txt'),
+ }).toMatchInlineSnapshot(`
+ {
+ "inputEchoesArgs": true,
+ "outputComplete": true,
+ "title": "bash",
+ }
+ `)
+})
+
+it('trajectory and waterfall surface the run_code sub-calls with real timing', async () => {
+ boot()
+ await openFixtureSession()
+
+ // Switch to the trajectory tab (same slot ring the chat view registers in).
+ fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' }))
+ await waitFor(() => {
+ expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull()
+ }, { timeout: 10_000 })
+ const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
+ expect({
+ // Three Sub cells nested under the run_code Tool cell, in dispatch order,
+ // each with a real +N.Ns own-duration off the start/settle pair (the
+ // fixture spaces every event 800ms apart — never the em dash).
+ subCells: subCells.map(cell => visibleText(cell)),
+ }).toMatchInlineSnapshot(`
+ {
+ "subCells": [
+ "#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
+ "#52Subread · {"path":"notes/demo.txt"}+0.8s",
+ "#53Subread · {"path":"notes/missing.txt"}+0.8s",
+ ],
+ }
+ `)
+
+ // Waterfall: each sub-call draws a measured lane scaled into the parent
+ // turn's dispatch window.
+ fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
+ await waitFor(() => {
+ expect(document.querySelector('[data-subspan]')).not.toBeNull()
+ }, { timeout: 10_000 })
+ const lanes = [...document.querySelectorAll('[data-subspan]')]
+ expect({
+ lanes: lanes.map(lane => ({
+ label: visibleText(lane.querySelector('[class*="subTag"]') ?? lane),
+ title: lane.querySelector('[data-timing]')?.getAttribute('title'),
+ timing: lane.querySelector('[data-timing]')?.getAttribute('data-timing'),
+ })),
+ }).toMatchInlineSnapshot(`
+ {
+ "lanes": [
+ {
+ "label": "bash",
+ "timing": "measured",
+ "title": "bash · 0.80s",
+ },
+ {
+ "label": "read",
+ "timing": "measured",
+ "title": "read · 0.80s",
+ },
+ {
+ "label": "read",
+ "timing": "measured",
+ "title": "read · 0.80s",
+ },
+ ],
+ }
+ `)
+})
diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts
new file mode 100644
index 0000000000..32c51a2a2a
--- /dev/null
+++ b/apps/web/tests/code-mode-round.e2e.ts
@@ -0,0 +1,146 @@
+// Web e2e scenario: a Code Mode round trip. The scaffold boots the SAME
+// shipped tree with the tools row patched to mode: code (the run_code-only
+// wire), a real chromium sends a prompt engineered to elicit one run_code
+// program with several sub-calls, and the UI must render the code-variant
+// parent row with its always-visible nested sub-rows — each sub-row the same
+// component a native call renders through — plus details-panel resolution for
+// a clicked sub-row. Drive steps wait only on generic completion
+// (whenTurnSettled); assertion steps run in replay/refresh only.
+// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
+// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+ captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
+
+const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+
+// The scenario's one drive prompt: elicits one program with a bash sub-call
+// and a failing read the program tolerates — the sub-row set the assertions
+// (and the PR gif) need. Never asserted against model prose.
+const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt '
+ + 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.'
+
+describe('web e2e: Code Mode round renders nested sub-calls', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ const sessionEvents: SessionEvent[] = []
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({
+ toolsMode: 'code',
+ ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
+ })
+ scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ // Fresh world: connect a Workspace so the composer scenarios start live.
+ await connectFreshWorkspace(page)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('drives the recorded prompt to a settled turn (all modes)', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-drive'))
+ if (MODE !== 'record') {
+ // Drift guard: the committed fixture must carry exactly the drive prompt.
+ expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+ }
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ const settled = scaffold.whenTurnSettled()
+ await input.fill(PROMPT)
+ await input.press('Enter')
+ const sessionId = await settled
+ if (MODE === 'record') {
+ await recordFixture(scaffold, sessionId, FIXTURE)
+ }
+ }, 200_000)
+
+ it.skipIf(MODE === 'record')('the durable log carries run_code with full-content sub-dispatches', () => {
+ // Wire discipline: code mode collapsed the call surface to run_code.
+ const calls = sessionEvents.filter(event => event.type === 'tool/call')
+ expect(calls.length).toBeGreaterThanOrEqual(1)
+ expect(new Set(calls.map(call => (call.data as { name: string }).name))).toEqual(new Set(['run_code']))
+ // Sub-dispatches logged with the complete tool/result vocabulary.
+ const dispatches = sessionEvents.filter(event => (event.type as string) === 'tool/code-dispatch')
+ expect(dispatches.length).toBeGreaterThanOrEqual(2)
+ for (const dispatch of dispatches) {
+ const data = dispatch.data as unknown as {
+ parentCallId: string
+ subCallId: string
+ name: string
+ isError: boolean
+ content: { type: string }[]
+ }
+ expect(data.subCallId.startsWith(`${data.parentCallId}:code:`)).toBe(true)
+ expect(Array.isArray(data.content)).toBe(true)
+ expect(typeof data.isError).toBe('boolean')
+ }
+ const bash = dispatches.find(dispatch => (dispatch.data as { name: string }).name === 'bash')
+ expect(bash).toBeDefined()
+ const bashContent = (bash!.data as { content: { type: string; text?: string }[] }).content
+ expect(bashContent.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('CODE_ROUND_OK')
+ })
+
+ it.skipIf(MODE === 'record')('renders the code parent row with always-visible nested sub-rows', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows'))
+ await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ // The parent run_code row wears the code variant with the model-authored
+ // description as its summary (the PR1 presentCall contract).
+ const codeRow = page.locator('[data-variant="code"]').first()
+ await codeRow.waitFor({ timeout: 10_000 })
+ // Nested rows are visible WITHOUT any expand interaction, inside the
+ // sub-call nest, each rendered by the same components as native rows:
+ // the bash sub-call landed in the bash sample registration.
+ const nest = page.locator('[data-subcalls]').first()
+ await nest.waitFor({ timeout: 10_000 })
+ expect(await nest.locator('[data-sample="bash-global"]').count()).toBeGreaterThanOrEqual(1)
+ // The failing read sub-call wears the same error state a native failed
+ // row wears (the recorded program tolerates a read of missing.txt).
+ expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('a sub-row click opens the details panel on the sub-call material', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details'))
+ const nest = page.locator('[data-subcalls]').first()
+ await nest.locator('[data-sample="bash-global"]').first().click()
+ // The details column opens (width > 0) and shows the sub-call's complete
+ // output — the full-content log contract, no truncation marker anywhere.
+ await page.waitForFunction(() => {
+ const frame = document.querySelector('[class*="frame"]')
+ if (frame === null) return false
+ return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0
+ }, undefined, { timeout: 10_000 })
+ await expect.poll(() => page.getByText('CODE_ROUND_OK', { exact: false }).count(), { timeout: 5_000 })
+ .toBeGreaterThanOrEqual(1)
+ })
+
+ it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-aria'))
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+ })
+
+ it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ })
+})
diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts
new file mode 100644
index 0000000000..4b54242495
--- /dev/null
+++ b/apps/web/tests/lifecycle-chrome.e2e.ts
@@ -0,0 +1,165 @@
+// Web e2e scenarios: lifecycle & chrome — the workspace-aware first-send
+// flow over the real wire, reload recovery, and the dark-mode token cascade.
+// One tiny recorded turn (text-only) drives the whole spec: the empty-state
+// hero materializes a real Workspace + Session on first send (the jsdom
+// workspace-flow suite pins the object-layer state machine over the fixture
+// client; THIS spec pins the same flow through HTTP RPC + SSE + the host
+// gateway), reload replays everything from the log (zero further model
+// calls), and the theme scenario proves the shipped dark palette actually
+// cascades: attribute -> alias token flip -> painted surface change. Per the
+// lane's scope ruling there is no theme/layout golden (aria is color-blind);
+// the hero's waiting state gets the one golden here.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+ acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
+// Post-reload golden: the same settled conversation rebuilt purely from
+// persistence + history — byte-equal rendering is exactly the recovery claim.
+const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
+const MODE = webSnapshotMode()
+
+const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
+
+describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ const sessionEvents: SessionEvent[] = []
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
+ scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ // Fresh world: connect a Workspace so the composer scenarios start live.
+ await connectFreshWorkspace(page)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('sends the first prompt from the empty-state hero (all modes)', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
+ if (MODE !== 'record') {
+ expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+ }
+ // The blank frame renders the hero, not the resident composer: the
+ // headline plus the guidance placeholder are the empty state's anchors.
+ await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ if (MODE !== 'record') {
+ // Golden of the hero's stable waiting state (captured before any send;
+ // the conversation-region goldens belong to the other scenarios).
+ const snapshot = await captureStableAria(page, '[class*="frame"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
+ }
+ const settled = scaffold.whenTurnSettled()
+ await input.fill(PROMPT)
+ await input.press('Enter')
+ const sessionId = await settled
+ if (MODE === 'record') {
+ await recordFixture(scaffold, sessionId, FIXTURE)
+ }
+ }, 200_000)
+
+ it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
+ // Browser: the sidebar tree now carries the auto-created workspace group
+ // with its one session, and the opened session is the selected row.
+ await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
+ await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ // Host: the session's durable header cwd is the workspace flow's
+ // create-by-name target (/workspace, the composer's
+ // default draft name) — the proof the send went through workspace
+ // materialization rather than a bare default-cwd session.
+ const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
+ expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
+ const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
+ expect(turnEnds).toHaveLength(1)
+ expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
+ // Fold a layout preference into the same reload: collapse the sidebar
+ // (persisted under dsh.layout.panels) before reloading.
+ await page.getByRole('button', { name: 'Collapse sidebar' }).click()
+ await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ // Layout persisted: the sidebar comes back collapsed.
+ await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
+ // Selection persisted (dsh.sessions.current) and history replayed: the
+ // recorded turn re-renders from session.history with zero model calls —
+ // the replay cursor was fully consumed before the reload, so any stray
+ // request would fail the scenario loudly at close().
+ await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ // Expand back and confirm the tree still lists the materialized session.
+ await page.getByRole('button', { name: 'Open sidebar' }).click()
+ await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
+ // Golden of the recovered conversation region: rebuilt from the log, it
+ // must render the same settled transcript the live turn produced.
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 90_000)
+
+ it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
+ // This scenario pins the ThemeService's DOM contract seam directly (the
+ // body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
+ // user gesture above it (Settings -> Appearance cubes) is owned by
+ // settings-chrome.e2e.ts. Driving the attribute here keeps the cascade
+ // pinned independently of the settings surface's own lifecycle.
+ const sample = async (): Promise<{ token: string; sidebarBg: string; bodyBg: string }> =>
+ await page.evaluate(() => {
+ const sidebar = document.querySelector('[class*="sidebar"], [class*="rail"]') ?? document.body
+ return {
+ token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
+ sidebarBg: getComputedStyle(sidebar).backgroundColor,
+ bodyBg: getComputedStyle(document.body).backgroundColor,
+ }
+ })
+ const light = await sample()
+ await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
+ const dark = await sample()
+ // The alias token itself must flip — the cascade's root fact.
+ expect(dark.token).not.toBe(light.token)
+ // And a real painted surface must consume it (not just variables in a
+ // void): at least one of the sampled backgrounds repaints.
+ expect(dark.sidebarBg !== light.sidebarBg || dark.bodyBg !== light.bodyBg).toBe(true)
+ // Removing the attribute restores the light values exactly (the palettes
+ // live in one stylesheet; activation is attribute-only by design).
+ await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
+ const restored = await sample()
+ expect(restored).toEqual(light)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md'])
+ })
+})
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
new file mode 100644
index 0000000000..692210b352
--- /dev/null
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -0,0 +1,211 @@
+// Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
+// and transient-retry recovery, all through the real composition and wire.
+// The model seam is dsh-llm-replay with override sidecars: `hang` (+ a
+// readyFile marker) makes mid-stream cancel deterministic by construction,
+// `throw` entries express provider failures by stable code, and `{ patches }`
+// augmentation injects a transient throw before the recorded success so
+// llm-retry's recovery is proven end-to-end in the browser. Sidecar CONTENT
+// is authored here (single-sourced against the fixture via deriveReplayScript
+// — no committed copy of recorded chunks); the file is a per-run artifact in
+// the temp workspace. One recorded base fixture serves all three scenarios.
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
+import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+// One golden per interactive end-state: what the user is left looking at
+// after cancel, after a non-retryable failure (pins the FIXME(web-error-surface)
+// gap as a reviewable artifact: NO error copy in the tree), and after retry
+// recovery — three genuinely different terminal surfaces of one fixture.
+const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
+const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
+const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
+const MODE = webSnapshotMode()
+
+// The recorded base: one text-only turn whose derived script the sidecars
+// patch. Kept deliberately tool-free so the derived script is exactly one
+// model call.
+const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
+
+/** turn/end reasons observed, in order. */
+function turnEndReasons(events: SessionEvent[]): string[] {
+ return events
+ .filter(e => e.type === 'turn/end')
+ .map(e => (e as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind)
+}
+
+describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
+ let scaffold: WebScaffold | undefined
+ let browser: Browser | undefined
+ let page: Page
+ let tripwire: ReturnType
+ let sessionEvents: SessionEvent[]
+ let sidecarDir: string | undefined
+
+ afterEach(async () => {
+ // scaffold.close() failures MUST fail the scenario: assertConsumed() is
+ // the fixture-drift tripwire and cleanup problems are real defects. Run
+ // every teardown step regardless, then rethrow what failed.
+ const failures: unknown[] = []
+ await browser?.close().catch((error: unknown) => failures.push(error))
+ browser = undefined
+ const closing = scaffold
+ scaffold = undefined
+ await closing?.close().catch((error: unknown) => failures.push(error))
+ if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
+ sidecarDir = undefined
+ if (failures.length === 1) throw failures[0]
+ if (failures.length > 1) throw new AggregateError(failures, 'live-interactions teardown failed')
+ })
+
+ /** Boot scaffold + page with an optional override doc materialized per run. */
+ async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise {
+ sessionEvents = []
+ let overridePath: string | undefined
+ if (buildOverride !== undefined) {
+ // The sidecar CONTENT is authored in this spec; the file is a per-run
+ // artifact minted in a spec-owned temp dir. It must exist BEFORE the
+ // scaffold boots — installLlmReplay resolves the script at install.
+ sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-'))
+ overridePath = join(sidecarDir, 'replay.override.json')
+ await writeFile(overridePath, JSON.stringify(buildOverride(sidecarDir)))
+ }
+ scaffold = await launchWebScaffold({
+ replayFixture: FIXTURE,
+ ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
+ })
+ scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ // Fresh world: connect a Workspace so the composer scenarios start live.
+ await connectFreshWorkspace(page)
+ }
+
+ /**
+ * Type the recorded prompt and send, with the settled barrier pre-armed.
+ * Returned WRAPPED ({ settled }) — a bare returned promise would be
+ * flattened by the caller's await, blocking on turn/end before the caller
+ * can act mid-turn (the cancel scenario's whole point).
+ */
+ async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType }> {
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ const settled = scaffold!.whenTurnSettled(timeoutMs)
+ await input.fill(PROMPT)
+ await input.press('Enter')
+ return { settled }
+ }
+
+ it.skipIf(MODE !== 'record')('records the base fixture live through the composer', async () => {
+ await launch()
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-interactions-record'))
+ const { settled } = await sendPrompt(180_000)
+ const sessionId = await settled
+ await recordFixture(scaffold!, sessionId, FIXTURE)
+ }, 200_000)
+
+ it.skipIf(MODE === 'record')('cancels a hung stream deterministically via the readyFile marker', async () => {
+ expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+ let marker = ''
+ await launch((sidecarHome) => {
+ marker = join(sidecarHome, '.hang-ready')
+ return { patches: [{ at: 0, entry: { kind: 'hang', readyFile: marker } }] }
+ })
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-cancel'))
+ const { settled } = await sendPrompt()
+ // The marker IS the synchronization: the stream is provably parked in the
+ // hang (prefix chunks delivered to the loop) before the stop click.
+ await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
+ await page.getByRole('button', { name: 'Stop generating' }).click()
+ await settled
+ expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
+ // Composer recovered; no streaming node lingers. The host settled first
+ // (awaited above), but the abort frame reaches the browser over SSE — the
+ // frozen-partial swap is eventually consistent, so poll rather than count.
+ await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
+ await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
+ // Golden of the aborted end-state: the prompt bubble plus the frozen
+ // partial ('partial' is the hang entry's replayed prefix) and no more.
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+ await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 120_000)
+
+ it.skipIf(MODE === 'record')('surfaces a non-retryable AUTH failure without retrying', async () => {
+ await launch(() => ({
+ patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }],
+ }))
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-error-auth'))
+ const { settled } = await sendPrompt()
+ await settled
+ expect(turnEndReasons(sessionEvents).at(-1)).toBe('error')
+ // AUTH is outside llm-retry's retryable set: no retry record.
+ expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0)
+ // Product gap found by this lane, pinned as-is: the client consumes no
+ // agent/error frames and a pre-chunk failure freezes no partial, so THIS
+ // failure renders no error copy anywhere — the user sees the send simply
+ // stop. FIXME(web-error-surface): assert visible error text here once the
+ // web UI grows an error rendering; until then the pinned contract is
+ // "no crash, composer recovers, turn logged as error".
+ await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
+ expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
+ // Golden of the same gap: the prompt bubble alone, no error copy in the
+ // tree — the diff that changes when web-error-surface lands.
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+ await compareOrRefreshGolden(ERROR_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 120_000)
+
+ it.skipIf(MODE === 'record')('recovers a transient SERVER failure through llm-retry and completes', async () => {
+ const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
+ expect(derived).toHaveLength(1)
+ await launch(() => ({
+ patches: [
+ { at: 0, entry: { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' } },
+ // Append the fixture's own success as the retry attempt — single-
+ // sourced from the recording, never copied into a committed sidecar.
+ { at: 1, entry: derived[0]! },
+ ],
+ }))
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-retry'))
+ // llm-retry backs off ~500ms before the second attempt.
+ const { settled } = await sendPrompt(60_000)
+ await settled
+ expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed')
+ // The durable retry record proves the second attempt (request/header logs
+ // only on change, so attempt count is invisible there).
+ expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
+ await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
+ // Golden of the recovered end-state: indistinguishable from a clean
+ // completion — retries are deliberately invisible in the transcript.
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+ await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 120_000)
+
+ it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+ await assertFixtureInventory(SNAPSHOT_DIR, [
+ 'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md',
+ ])
+ })
+})
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
new file mode 100644
index 0000000000..bbae7363df
--- /dev/null
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -0,0 +1,190 @@
+// Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
+// Waterfall), the details column, and sidebar search, all over ONE rich
+// two-turn seeded fixture rendered purely from the log (the seeded-history
+// pattern: zero model calls in replay, so every surface here is the client
+// fold + host history RPC, not replay binding). The seed is recorded live
+// under the standard discipline: turn 1 produces a bash call plus two
+// parallel reads in one assistant message (tool-call density for the
+// trajectory/waterfall lanes and a details-capable bash row), turn 2 a
+// markdown-rich reply (a second turn so the waterfall has two lanes).
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
+const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
+const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
+const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
+const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md')
+const MODE = webSnapshotMode()
+const SEED_ID = 'navigation-panes-web-e2e'
+
+// Turn 1 leads with a distinctive word: the session-title fallback takes the
+// first words of the first message, so the sidebar-search scenario has a
+// known-matching query ('navscenario') without depending on a live title call.
+const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.'
+const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.'
+
+describe('web e2e: navigation & panes over a rich seeded session', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ // The workspace-aware flow runs sessions in /workspace;
+ // the read targets must live in that session cwd (pre-creation is safe:
+ // create-by-name adopts an existing directory).
+ const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
+ await mkdir(sessionCwd, { recursive: true })
+ await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n')
+ await writeFile(join(sessionCwd, 'nav-b.md'), '# beta nav\n')
+ if (MODE !== 'record') {
+ const raw = await readFile(SEED, 'utf8')
+ expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
+ .toEqual([PROMPT_TURN1, PROMPT_TURN2])
+ await seedSession(scaffold, raw, SEED_ID)
+ }
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record'))
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ let sessionId: Awaited> | undefined
+ for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) {
+ const settled = scaffold.whenTurnSettled()
+ // Turn 2 types into the same composer once turn 1 unlocks it.
+ await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
+ await input.fill(prompt)
+ await input.press('Enter')
+ sessionId = await settled
+ }
+ await recordFixture(scaffold, sessionId!, SEED)
+ // Fixture honesty: the recording must carry the shape the replay
+ // scenarios assert on — three calls in turn 1 and two closed turns.
+ const recorded = parseSessionLog(await readFile(SEED, 'utf8'))
+ expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2)
+ const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call')
+ expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
+ }, 400_000)
+
+ it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open'))
+ // Expand the collapsed group row, then open the revealed session row.
+ const groupRow = page.locator('[role="treeitem"]').first()
+ await groupRow.waitFor({ timeout: 15_000 })
+ await groupRow.click()
+ const sessionRow = page.locator('[role="treeitem"]').nth(1)
+ await sessionRow.waitFor({ timeout: 10_000 })
+ await sessionRow.click()
+ await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
+ }, 90_000)
+
+ it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
+ // Runs after the session is open: a cold summary carries no title (the
+ // sidebar shows the cwd basename), and the durable title lands with the
+ // attach subscription's baseline — which is itself worth pinning: search
+ // matches the title the user sees, not a hidden cold field.
+ const search = page.getByPlaceholder('Search name, keywords', { exact: false })
+ await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ // Negative: a garbage query empties the tree (group rows hide too).
+ await search.fill('zzzqx-no-such-session')
+ await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0)
+ // Positive: a title word narrows to the matched session + its group,
+ // force-expanded by search mode (case-insensitive client-side filter).
+ await search.fill('navscenario')
+ await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
+ // Clear restores the unfiltered tree.
+ await page.getByRole('button', { name: 'Clear search' }).click()
+ await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
+ await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
+ await page.getByRole('tab', { name: 'Trajectory' }).click()
+ // Two sticky turn sections; turn 1's step group summarizes its tool mix
+ // (bash + the two parallel reads collapse to 'bash read×2').
+ await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+ await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+ await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
+ const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
+ .split(SEED_ID).join('{{seededId}}')
+ await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall'))
+ await page.getByRole('tab', { name: 'Waterfall' }).click()
+ // The stats header rides the waterfall body. The span fold counts THREE
+ // spans for this two-turn log: only assistant/steering nodes carry a turn
+ // number, so the first user message lands in a turn-0 prologue span (a
+ // P-I placeholder shape — pinned as-is; real spans are deferred to
+ // P-III per the view's deviation ledger). Calls: bash + two reads.
+ await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1)
+ // One lane per span, tagged by turn number, prologue included.
+ for (const tag of ['turn 0', 'turn 1', 'turn 2']) {
+ await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+ }
+ const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
+ .split(SEED_ID).join('{{seededId}}')
+ await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
+ await page.getByRole('tab', { name: 'Chat' }).click()
+ // The bash toolview row routes its click to openDetails (read rows are
+ // expand-in-place instead — the seeded-history scenario owns that fold).
+ const bashRow = page.locator('[data-sample="bash-global"]').first()
+ await bashRow.waitFor({ timeout: 15_000 })
+ // Open/closed is the frame's collapsed attribute: the column collapses to
+ // width 0 but its subtree deliberately never unmounts (hidden, not
+ // absent), so element presence/visibility cannot express the state.
+ const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
+ expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
+ await bashRow.click()
+ await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull()
+ // The open panel shows the selected call's name, arguments, and durable
+ // result (NAVIGATION_OK appears in the chat row too, hence >= 2 total).
+ await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
+ // Golden of the open panel: tool name header, Input args, Output result.
+ const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd))
+ .split(SEED_ID).join('{{seededId}}')
+ await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE)
+ await page.getByRole('button', { name: '关闭详情' }).click()
+ await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull()
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, [
+ 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md',
+ ])
+ })
+})
diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts
new file mode 100644
index 0000000000..6ecdb683b3
--- /dev/null
+++ b/apps/web/tests/question-composer.e2e.ts
@@ -0,0 +1,154 @@
+// Web e2e scenario: the resident question composer. The shipped composition
+// already exposes ask_user_question (the ui-question row's node half mounts
+// the tool), so a recorded turn where the model asks blocks mid-turn on the
+// real userInteraction seam: the composer renders in the browser, the test
+// answers through it, and the turn completes with the answer in the log.
+// Replay is fully deterministic — the question content arrives from replayed
+// chunks, the composer wait is real, and the answer click is the test's own
+// gesture (the ONE place a drive step legitimately reacts to model content:
+// the turn cannot complete without it, in record and replay alike).
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+// Second golden: the answered transcript — the question resolved into its
+// tool round trip and the final reply, the state the waiting golden cannot see.
+const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
+const MODE = webSnapshotMode()
+
+// The options carry long descriptions on purpose: the squeeze assertion below
+// needs option copy that WRAPS, which is the only shape that reproduces a
+// collapsed row painting its copy outside its own box.
+const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.'
+
+describe('web e2e: resident question composer round trip', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ const sessionEvents: SessionEvent[] = []
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
+ scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ // Fresh world: connect a Workspace so the composer scenarios start live.
+ await connectFreshWorkspace(page)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('asks through the composer, answers, and completes with the answer logged', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-question'))
+ if (MODE !== 'record') {
+ expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+ }
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
+ await input.fill(PROMPT)
+ await input.press('Enter')
+
+ // The composer takes over the input area while the tool blocks. Its
+ // presence is a STABLE waiting state (not a transient): it stays until
+ // answered, so a plain waitFor is race-free.
+ const composer = page.locator('[data-question-key]')
+ await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
+ await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
+
+ if (MODE !== 'record') {
+ // This golden owns the stable question surface; the answered-state
+ // golden below owns the resulting transcript.
+ const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+ }
+
+ // Squeezed card: the option rows are the capped card's scroll content, so
+ // shrinking the seat must push overflow into the option list, never
+ // collapse a row below the height its own copy needs — a collapsed row
+ // paints its centered copy outside the row box, over the title and the
+ // neighbouring rows. Measured on the live composer at seat heights that
+ // force the cap, then restored for the answer gesture below. Replay only:
+ // record mode must reach the recording write below, not abort on layout.
+ if (MODE !== 'record') {
+ const original = page.viewportSize() ?? { width: 1680, height: 1000 }
+ for (const height of [520, 440, 380]) {
+ await page.setViewportSize({ width: 900, height })
+ const squeeze = await composer.evaluate((card) => {
+ // Role/ARIA selectors, not the CSS-module class names: the built
+ // client hashes those.
+ const rows = [...card.querySelectorAll(
+ '[role="radio"], [role="checkbox"], [aria-expanded]',
+ )]
+ const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
+ const box = row.getBoundingClientRect()
+ const inner = child.getBoundingClientRect()
+ return Math.max(box.top - inner.top, inner.bottom - box.bottom)
+ })))
+ const list = rows[0]?.parentElement ?? null
+ return {
+ rows: rows.length,
+ spill: Math.max(...spill),
+ // Wrapped copy is the shape that overflows a collapsed row, and a
+ // scrolling list proves the seat is genuinely capped. Without both,
+ // the spill assertion would hold vacuously.
+ wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
+ scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
+ }
+ })
+ expect(squeeze.rows).toBeGreaterThan(0)
+ expect(squeeze.wrappedRows).toBeGreaterThan(0)
+ expect(squeeze.scrolls).toBe(true)
+ // Sub-pixel tolerance: every row's copy stays inside its border box.
+ expect(squeeze.spill).toBeLessThan(0.6)
+ }
+ await page.setViewportSize(original)
+ }
+
+ await composer.getByRole('radio', { name: 'Blue' }).click()
+ // Submit: Enter on the focused option (the composer's documented submit).
+ await composer.getByRole('radio', { name: 'Blue' }).press('Enter')
+
+ const sessionId = await settled
+ if (MODE === 'record') {
+ await recordFixture(scaffold, sessionId, FIXTURE)
+ return
+ }
+ // World state: the tool result carries the chosen answer, and DONE lands.
+ const results = sessionEvents.filter(e => e.type === 'tool/result')
+ expect(JSON.stringify(results.at(-1))).toContain('Blue')
+ await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ // Composer gone; regular input restored.
+ expect(await page.locator('[data-question-key]').count()).toBe(0)
+ await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
+ // Golden of the answered transcript: the ask_user_question round trip
+ // rendered as history (question tool row + DONE), composer takeover gone.
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 200_000)
+
+ it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+ await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md'])
+ })
+})
diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts
index 131f3fa5fd..10f374c920 100644
--- a/apps/web/tests/replay-round-trip.e2e.ts
+++ b/apps/web/tests/replay-round-trip.e2e.ts
@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
-import { saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
@@ -47,6 +47,8 @@ describe('web e2e: fresh round trip through the real assembly', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ // Fresh world: connect a Workspace so the composer scenarios start live.
+ await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index babfbde919..d57b934068 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -17,8 +17,8 @@
// the open llm seam post-boot with installLlmReplay on the settled root ctx
// (the plugin-row path discards the ReplayHandle; the direct install keeps
// assertConsumed for the teardown fixture-consumption check).
-import { existsSync, readFileSync } from 'node:fs'
-import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -31,8 +31,14 @@ import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot'
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
-import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
-import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import SessionStore, {
+ packChunkRuns,
+ SESSION_FORMAT_VERSION,
+ SessionId,
+ type Session,
+ type SessionEvent,
+ type SessionHeader,
+} from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -58,21 +64,11 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
// Replay publishes the provider catalog the gateway routes to (providers
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
-// catch-all would leave resolveModelContext unroutable and compact-basic's
+// catch-all would leave resolveModelInfo unroutable and compact-basic's
// post-step pressure check would warn every step). The published
// contextWindow keeps that pressure path provably inert for small fixtures.
const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
-/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */
-function loadRootEnv(): void {
- const envPath = join(REPO_ROOT, '.env')
- if (!existsSync(envPath)) return
- for (const line of readFileSync(envPath, 'utf8').split('\n')) {
- const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
- if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
- }
-}
-
/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
export interface WebScaffold {
/** The active snapshot mode this scaffold booted under. */
@@ -101,8 +97,21 @@ export interface LaunchOptions {
* mounts).
*/
replayFixture?: string
+ /**
+ * Optional replay.override.json sidecar (whole-script replacement or
+ * `{ patches }` augmentation) for throw/hang scenarios not expressible as
+ * recorded chunks; replay/refresh only.
+ */
+ replayOverride?: string
/** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
paceMs?: number
+ /**
+ * Tool presentation mode patched onto the shipped `tools` row (`code`
+ * collapses the wire to run_code + the SDK prompt section). Omit for the
+ * yml default. The code runtime row is always in the tree, so no extra
+ * insertion is needed.
+ */
+ toolsMode?: 'native' | 'code' | 'both'
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -123,12 +132,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise JSON.stringify(event)),
+ ...packChunkRuns(session.events).map(record => JSON.stringify(record)),
'',
].join('\n')
}
@@ -438,3 +449,17 @@ export function watchConsole(page: Page): { warnings: string[]; pageErrors: stri
page.on('pageerror', (error) => { pageErrors.push(String(error)) })
return { warnings, pageErrors }
}
+
+/**
+ * Remove only connection-loss warnings emitted after an intentional reload.
+ * Earlier warnings and all gap-repair/discontinuity warnings remain fatal.
+ * @param tripwire - the live console-warning collector.
+ * @param warningStart - warning count captured immediately before reloading.
+ */
+export function acknowledgeReloadConnectionLoss(
+ tripwire: ReturnType,
+ warningStart: number,
+): void {
+ const reloadWarnings = tripwire.warnings.splice(warningStart)
+ tripwire.warnings.push(...reloadWarnings.filter(text => !/connection lost/i.test(text)))
+}
diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts
index c1616bb724..313573ea94 100644
--- a/apps/web/tests/session-title.snapshot.ts
+++ b/apps/web/tests/session-title.snapshot.ts
@@ -10,9 +10,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
- { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
+ { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
+ { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
@@ -94,10 +97,10 @@ it('projects initial and revised durable titles through the built nine-plugin fi
})
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
- const projectCount = await within(tree).findByText('4 sessions')
- const projectRow = projectCount.closest('[role="treeitem"]')
- if (projectRow === null) throw new Error('fixture project row missing')
- fireEvent.click(projectRow)
+ // The fixture Intent selects the workspace, so the current-group effect
+ // already expanded it; clicking the header would now collapse (the twist
+ // stays live since intent stopped forcing expansion).
+ await within(tree).findByText('4 sessions')
const initialLabel = 'Fixture 历史会话'
const initialRowLabel = await screen.findByText(initialLabel)
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
new file mode 100644
index 0000000000..90f0b3964b
--- /dev/null
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -0,0 +1,170 @@
+// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
+// section switching, both close paths), the Appearance preference row (the
+// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
+// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
+// and the Language row (settings-scoped localization + persisted dsh.locale).
+// Zero model calls: everything is pure client + persistence state on a blank
+// frame, so there is no fixture and a stray stream would fail loud on the
+// open llm seam.
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { join } from 'node:path'
+import {
+ acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
+const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
+const MODE = webSnapshotMode()
+
+describe('web e2e: settings modal, appearance gesture, language switch', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('opens the settings dialog, switches sections, and closes by every path', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell'))
+ const trigger = page.getByRole('button', { name: '设置', exact: true })
+ expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog')
+ expect(await trigger.getAttribute('aria-expanded')).toBe('false')
+ await trigger.click()
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ await dialog.waitFor({ timeout: 10_000 })
+ expect(await trigger.getAttribute('aria-expanded')).toBe('true')
+ // General is the active section by default; its skeleton rows plus the
+ // functional Language and Appearance rows render.
+ expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
+ await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
+ await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
+ // Golden of the freshly opened dialog (default zh, General active).
+ const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
+ // Section switch: aria-current moves; Models is deliberately empty.
+ await dialog.getByRole('button', { name: '模型' }).click()
+ await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
+ expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
+ // Close path 1: Escape.
+ await page.keyboard.press('Escape')
+ await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
+ expect(await trigger.getAttribute('aria-expanded')).toBe('false')
+ // Close path 2: the header close button (focus lands there on open).
+ await trigger.click()
+ await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click()
+ await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('flips the theme through the Appearance cubes and persists across reload', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
+ const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
+ await page.evaluate(() => ({
+ attr: document.body.hasAttribute('data-ds-dark-theme'),
+ token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
+ stored: localStorage.getItem('dsh.theme'),
+ }))
+ // Pin the OS scheme to light so the default `system` preference resolves
+ // light and the dark flip below is unambiguously the gesture's doing.
+ await page.emulateMedia({ colorScheme: 'light' })
+ const light = await readState()
+ expect(light.attr).toBe(false)
+
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ await dialog.waitFor({ timeout: 10_000 })
+ const darkCube = dialog.getByRole('button', { name: '深色' })
+ expect(await darkCube.getAttribute('aria-pressed')).toBe('false')
+ await darkCube.click()
+ // The full cascade: pressed state, persisted preference, body attribute,
+ // alias token flip — all from one real user gesture.
+ await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
+ const dark = await readState()
+ expect(dark.attr).toBe(true)
+ expect(dark.stored).toBe('dark')
+ expect(dark.token).not.toBe(light.token)
+ await page.keyboard.press('Escape')
+
+ // Reload: the preference survives boot (restore + presenter initial apply).
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ await page.emulateMedia({ colorScheme: 'light' })
+ const reloaded = await readState()
+ expect(reloaded.attr).toBe(true)
+ expect(reloaded.stored).toBe('dark')
+
+ // `system` follows the emulated OS scheme (dark stays dark, light clears).
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' })
+ await systemCube.click()
+ await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
+ await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
+ await page.emulateMedia({ colorScheme: 'dark' })
+ await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
+ // Restore for the specs that follow: light preference beats the emulated
+ // dark OS scheme, leaving the shared page in the light default.
+ await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click()
+ await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
+ await page.keyboard.press('Escape')
+ expect(tripwire.pageErrors).toEqual([])
+ }, 90_000)
+
+ it('switches the settings surface language and persists dsh.locale', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ const zhDialog = page.getByRole('dialog', { name: '设置' })
+ await zhDialog.waitFor({ timeout: 10_000 })
+ // The Language selector pill shows the active locale's own name.
+ const selector = zhDialog.getByRole('button', { name: '中文' })
+ expect(await selector.getAttribute('aria-haspopup')).toBe('menu')
+ await selector.click()
+ await page.getByRole('menuitem', { name: 'English' }).click()
+ // The settings-owned copy re-registers localized: dialog title, nav,
+ // Appearance labels. (Only the settings namespaces are localized today —
+ // the rest of the app's copy is intentionally out of this row's scope.)
+ const enDialog = page.getByRole('dialog', { name: 'Settings' })
+ await enDialog.waitFor({ timeout: 10_000 })
+ expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
+ await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
+ expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en')
+ // Reload keeps English; then restore zh so shared page state (and the
+ // other specs' 设置-anchored selectors + goldens) see the default again.
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ const enTrigger = page.getByRole('button', { name: 'Settings' })
+ await enTrigger.waitFor({ timeout: 10_000 })
+ await enTrigger.click()
+ await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
+ await page.getByRole('menuitem', { name: '中文' }).click()
+ await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 })
+ expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh')
+ await page.keyboard.press('Escape')
+ expect(tripwire.pageErrors).toEqual([])
+ }, 90_000)
+
+ it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
+ })
+})
diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts
new file mode 100644
index 0000000000..ac47ca4cd0
--- /dev/null
+++ b/apps/web/tests/slash-flow.snapshot.ts
@@ -0,0 +1,190 @@
+// @vitest-environment jsdom
+// Assembled keyless snapshot of the slash/input/session convergence under the
+// agent-parity model: the New Session view state locks the composer until a
+// Workspace is picked (connectWorkspace materializes the full Session+Agent),
+// the '/' menu serves the session's wire command catalog (sessions are always
+// agent-backed — no draft/materialized split), a leadingInput command claims,
+// submits over the wire, and notices its result, and the SAME composer
+// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
+// flips blank and surfaces the session in lists. This is the user-visible
+// acceptance anchor — package mocks do not substitute for the assembled
+// application transcript.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
+ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+ { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+ { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ { id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+ { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
+ { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
+ { id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
+ { id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
+ {
+ id: '@deepseek-ai/dsh-client-ui-workspace',
+ dir: 'ui-workspace',
+ url: '/plugins/ui-workspace.js',
+ rev: 'fx',
+ inject: [
+ '@deepseek-ai/dsh-client-runtime',
+ '@deepseek-ai/dsh-client-ui-conversation',
+ '@deepseek-ai/dsh-client-ui-sidebar',
+ ],
+ },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+ plugin.url,
+ readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+ __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+ __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+ observe(): void {}
+ disconnect(): void {}
+ unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+beforeEach(() => {
+ localStorage.clear()
+ document.title = 'DeepSeek Harness'
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+ setTimeout(() => { callback(0) }, 0) as unknown as number)
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+})
+
+afterEach(() => {
+ act(() => { unmount?.() })
+ unmount = undefined
+ cleanup()
+ delete win.__DSH_BOOT__
+ delete win.__ModuleLoader__
+ delete (globalThis as Record).__fxTiming
+ document.body.innerHTML = ''
+ document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+ document.title = ''
+ history.replaceState(null, '', '/')
+ vi.unstubAllGlobals()
+})
+
+/** Boot the complete built client graph against one keyless fixture branch. */
+function boot(search: string): void {
+ history.replaceState(null, '', `/${search}`)
+ const root = document.createElement('div')
+ root.id = 'root'
+ document.body.appendChild(root)
+ win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
+ act(() => {
+ const entry = new AppWebEntry(root, {
+ fetchBundle: (url) => {
+ const code = bundles.get(url)
+ return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
+ },
+ executeBundle: (code) => { (0, eval)(code) },
+ })
+ void entry.run()
+ unmount = () => { entry.dispose() }
+ })
+}
+
+/** Collapse decorative whitespace while preserving the text a user sees. */
+function visibleText(element: Element): string {
+ return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
+}
+
+/** Type into the machine-driven composer and let the change echo back. */
+async function typeComposer(composer: HTMLTextAreaElement, value: string): Promise {
+ fireEvent.change(composer, { target: { value } })
+ await waitFor(() => { expect(composer.value).toBe(value) })
+}
+
+it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
+ boot('?fixture=empty')
+
+ // View state: no session entity — the composer renders locked; only the
+ // workspace picker is live.
+ const locked = await screen.findByPlaceholderText(
+ 'Choose a workspace to start', {}, { timeout: 10_000 },
+ )
+ expect(locked.disabled).toBe(true)
+
+ // Pick (create) a Workspace: connectWorkspace materializes the full
+ // Session+Agent and the provider swaps in the live blank-session hero.
+ fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
+ .find(el => el.getAttribute('aria-haspopup') === 'menu')!)
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
+ const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
+ fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
+ target: { value: 'nova' },
+ })
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
+
+ const composer = await screen.findByPlaceholderText(
+ 'Describe what you want to build', {}, { timeout: 10_000 },
+ )
+ expect(composer.disabled).toBe(false)
+
+ // '/' opens the menu with the session's wire command catalog (the session
+ // is agent-backed from birth — the catalog is the single-address list).
+ await typeComposer(composer, '/')
+ const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
+ await waitFor(() => { expect(visibleText(menu)).toContain('echo') })
+ const menuText = visibleText(menu)
+
+ // Pick /echo (leadingInput): the claim token lands in the same textarea.
+ fireEvent.mouseDown(screen.getByRole('option', { name: /echo/ }))
+ await waitFor(() => { expect(composer.value).toBe('/echo ') })
+
+ // Type args and submit: the claim executes over the wire and notices its
+ // result; the token is consumed and the draft returns to plain text.
+ await typeComposer(composer, '/echo hello parser')
+ fireEvent.keyDown(composer, { key: 'Enter' })
+ await screen.findByText('hello parser', {}, { timeout: 10_000 })
+ await waitFor(() => { expect(composer.value).toBe('') })
+
+ // Slash execution does not flip blank: the selected row remains New Session.
+ const tree = screen.getByRole('tree', { name: 'Sessions' })
+ expect(within(tree).getByText('1 session')).toBeDefined()
+ expect(within(tree).getByText('New Session')).toBeDefined()
+
+ // First plain send through the SAME textarea: acceptance logs the user
+ // message and converts the existing sidebar row out of blank.
+ const before = composer
+ await typeComposer(composer, 'build me a parser')
+ fireEvent.keyDown(composer, { key: 'Enter' })
+ await waitFor(() => {
+ expect(screen.queryByText("Let's start building")).toBeNull()
+ }, { timeout: 10_000 })
+ await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
+ const after = document.querySelector('textarea')
+
+ expect({
+ menuHadEcho: menuText.includes('echo'),
+ menuHadCompact: menuText.includes('compact'),
+ composerSurvivedConversion: after === before,
+ sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
+ }).toMatchInlineSnapshot(`
+ {
+ "composerSurvivedConversion": true,
+ "menuHadCompact": true,
+ "menuHadEcho": true,
+ "sessionListed": "nova1 session",
+ }
+ `)
+})
diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts
index a3d511df16..3d16b72e35 100644
--- a/apps/web/tests/smoke-real.e2e.ts
+++ b/apps/web/tests/smoke-real.e2e.ts
@@ -1,9 +1,9 @@
// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
// list in a real chromium, screenshot every screen into .artifacts/ for the
// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
-// convention); the runner loads the repo-root .env explicitly because the CLI
-// only auto-loads .env from its cwd (a temp dir here, so sessions never land
-// in the repo's .sessions).
+// convention); vitest.web.config.ts loads the repo-root .env before this file
+// runs (the CLI only auto-loads .env from its cwd — a temp dir here, so
+// sessions never land in the repo's .sessions).
//
// Selector convention: CSS Modules hash as [hash]_[local], so class-substring
// selectors are unreliable — anchor on data-* attributes (data-variant /
@@ -24,18 +24,7 @@ import { pathToFileURL } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import { REPO_ROOT, probeFreePort, requireDist, saveFailureShot } from './support.ts'
-
-/** Repo-root .env → process.env (never overrides an already-set variable). */
-function loadRootEnv(): void {
- const envPath = join(REPO_ROOT, '.env')
- if (!existsSync(envPath)) return
- for (const line of readFileSync(envPath, 'utf8').split('\n')) {
- const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
- if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
- }
-}
-loadRootEnv()
+import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
function waitForReadyLine(child: ChildProcess): Promise {
return new Promise((resolveReady, reject) => {
@@ -147,7 +136,7 @@ async function detailsTrack(page: Page): Promise {
// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
// plugin's client bundle exists and exports apply, the loader fail-louds and
// the frame never appears.
-const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
+const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-settings', 'ui-settings-general', 'ui-models', 'ui-conversation', 'ui-question', 'ui-trajectory']
const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
@@ -271,6 +260,83 @@ describe('dsh web keyless CLI smoke', () => {
rmSync(workspace, { recursive: true, force: true })
}
})
+
+ it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => {
+ requireDist()
+ const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-'))
+
+ interface CodeModeProviderRequest {
+ messages?: { role?: string; content?: string }[]
+ tools?: { function?: { name?: string } }[]
+ }
+ let resolveProviderRequest!: (request: CodeModeProviderRequest) => void
+ const providerRequest = new Promise((resolve) => {
+ resolveProviderRequest = resolve
+ })
+ const provider = createServer((request, response) => {
+ let body = ''
+ request.setEncoding('utf8')
+ request.on('data', (chunk: string) => { body += chunk })
+ request.on('end', () => {
+ resolveProviderRequest(JSON.parse(body) as CodeModeProviderRequest)
+ response.writeHead(200, { 'content-type': 'text/event-stream' })
+ response.end([
+ 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
+ 'data: {"choices":[{"delta":{"content":"done"}}]}',
+ 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
+ 'data: [DONE]',
+ '',
+ ].join('\n\n'))
+ })
+ })
+ await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve))
+ const address = provider.address()
+ if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
+ const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
+ const child = spawn(
+ process.execPath,
+ ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
+ {
+ cwd: workspace,
+ env: {
+ ...process.env,
+ DEEPSEEK_API_KEY: 'keyless-web-code-mode',
+ DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
+ DSH_TOOLS_MODE: 'code',
+ DSH_HOME: join(workspace, '.dsh'),
+ TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ )
+ try {
+ const baseUrl = await waitForReadyLine(child)
+ const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
+ await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
+ sessionId: created.sessionId,
+ mode: 'queue',
+ content: [{ type: 'text', text: 'go' }],
+ })
+ const captured = await Promise.race([
+ providerRequest,
+ new Promise((_resolve, reject) => {
+ setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
+ }),
+ ])
+ expect(captured.tools?.map(tool => tool.function?.name)).toEqual(['run_code'])
+ const system = captured.messages?.find(message => message.role === 'system')
+ expect(system?.content).toContain('## Writing code for run_code')
+ expect(system?.content).toContain('declare const tools')
+ } finally {
+ const closed = child.exitCode === null
+ ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) })
+ : Promise.resolve()
+ if (child.exitCode === null) child.kill('SIGTERM')
+ await closed
+ await new Promise(resolveClose => provider.close(() => { resolveClose() }))
+ rmSync(workspace, { recursive: true, force: true })
+ }
+ })
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
@@ -327,6 +393,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
it('2+3 empty-state first send completes a real model round', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
+ // Fresh world: connect a Workspace so the composer starts live.
+ await connectFreshWorkspace(page)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
await screen(page, '02-empty-state')
diff --git a/apps/web/tests/snapshots/code-mode-round/session.jsonl b/apps/web/tests/snapshots/code-mode-round/session.jsonl
new file mode 100644
index 0000000000..6e9e481129
--- /dev/null
+++ b/apps/web/tests/snapshots/code-mode-round/session.jsonl
@@ -0,0 +1,35 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785013630399,"cwd":"{{cwd}}/workspace"}
+{"type":"turn/start","seq":0,"time":1785013630411,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1785013630418,"data":{"title":"Using ONE run_code program: run","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1785013630479,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1785013630480,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
+{"type":"assistant/chunk","seq":5,"time":1785013631481,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":6,"time0":1785013631481,"data":{"turn":1,"step":1,"index":0,"dt":[182,27,0,0,1,39,1,0,11,0,0,1,0,25,0,1,0,0,25,28,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,28,1,0,0,0,23,1,0,1,0,0,25,1,0,0,0,26,0,0,0,26,0,26,0,0,1,25,1,0,0,0,0,25,1,26,0,1,26,29],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Runs"," bash"," to"," echo"," \"","CODE","_RO","UND","_OK","\"\n","2","."," T","ries"," to"," read"," a"," file"," \"","missing",".txt","\""," and"," catches"," the"," error","\n","3","."," Returns"," an"," object"," with"," both"," outcomes","\n","4","."," They"," also"," want"," me"," to"," reply"," \"","D","ONE","\""," and"," stop"," after","\n\n","Let"," me"," write"," this"," program","."]}}
+{"type":"assistant/chunk","seq":81,"time":1785013632219,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":82,"time0":1785013632220,"data":{"turn":1,"step":1,"index":1,"dt":[26,0,1,27,0,0,1,22,0,26,1,41,11,0,0,26,0,1,26,0,0,0,0,26,0,0,0,0,1,25,1,0,0,0,0,27,1,0,0,0,0,25,1,0,0,0,25,0,0,0,1,0,28,1,0,0,45,0,0,15,0,0,0,66,1,0,0,1,0,0,0,0,0,12,0,0,0,0,30,1,0,0,0,0,21,1,0,26,0,0,0,0,0,26,27,0,0,0,0,1,25,1,0,0,0,25,1,25,0,0,27,26,26,0,1,0,26,0],"id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","args":["","{","\"","description","\"",": ","\"","Run"," bash"," echo"," and"," catch"," missing"," file"," read","\"",", ","\"","code","\"",": ","\"","\\n","const"," bash","Result"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_RO","UND","_OK","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_RO","UND","_OK","\\\"\\n","});\\n\\n","let"," read","Error"," ="," null",";\\n","try"," {\\n"," "," await"," tools",".read","({"," file","_path",":"," \\\"","missing",".txt","\\\""," });\\n","}"," catch"," (","e",")"," {\\n"," "," read","Error"," ="," {\\n"," "," tool","Name",":"," e",".t","ool","Name",",\\n"," "," message",":"," e",".message","\\n"," "," };\\n","}\\n\\n","return"," {"," bash",":"," bash","Result",".stdout",".text",".trim","(),"," read","Error"," };\\n","\"","}"]}}
+{"type":"assistant/chunk","seq":201,"time":1785013633103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."}}}}
+{"type":"assistant/chunk","seq":202,"time":1785013633103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}}}
+{"type":"assistant/chunk","seq":203,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}}}
+{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
+{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
+{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
+{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
+{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
+{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
+{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
+{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":214,"time":1785013633985,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":215,"time0":1785013633986,"data":{"turn":1,"step":2,"index":0,"dt":[106,27,1,0,0,23,1,29,0,1,25,1,22],"texts":["The"," program"," ran"," successfully","."," Let"," me"," now"," reply"," D","ONE"," as"," instructed","."]}}
+{"type":"assistant/chunk","seq":229,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":230,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
+{"type":"assistant/chunk","seq":231,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":232,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."}}}}
+{"type":"assistant/chunk","seq":233,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","seq":234,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}}}}
+{"type":"assistant/chunk","seq":235,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
+{"type":"step/end","seq":237,"time":1785013634225,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":238,"time":1785013634225,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
new file mode 100644
index 0000000000..99f92014ef
--- /dev/null
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -0,0 +1,29 @@
+- banner:
+ - navigation "Session hierarchy":
+ - 'button "Using ONE run_code program: run" [disabled]'
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."
+- 'button "Think The user wants me to write a single `run_code` program that:"':
+ - img
+ - text: "Think The user wants me to write a single `run_code` program that:"
+- button:
+ - img
+- text: Code Run bash echo and catch missing file read Echo CODE_ROUND_OK
+- button
+- text: Read missing.txt
+- button "Think The program ran successfully. Let me now reply DONE as instructed.":
+ - img
+ - text: Think The program ran successfully. Let me now reply DONE as instructed.
+- paragraph: DONE
+- text: cache hit 52% · 17,490 tokens · 1 turns · 2 steps
+- textbox "Message the agent"
+- button "Add attachment":
+ - img
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl
index 21218b459d..53a75267e5 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl
+++ b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl
@@ -5,51 +5,9 @@
{"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784973850888,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"assistant/chunk","seq":6,"time":1784973850889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
-{"type":"assistant/chunk","seq":7,"time":1784973851088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
-{"type":"assistant/chunk","seq":8,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
-{"type":"assistant/chunk","seq":9,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
-{"type":"assistant/chunk","seq":10,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
-{"type":"assistant/chunk","seq":11,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}}
-{"type":"assistant/chunk","seq":12,"time":1784973851107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
-{"type":"assistant/chunk","seq":13,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}}
-{"type":"assistant/chunk","seq":14,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}}
-{"type":"assistant/chunk","seq":15,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}
-{"type":"assistant/chunk","seq":16,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
-{"type":"assistant/chunk","seq":17,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
-{"type":"assistant/chunk","seq":18,"time":1784973851135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
-{"type":"assistant/chunk","seq":19,"time":1784973851135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
-{"type":"assistant/chunk","seq":20,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
-{"type":"assistant/chunk","seq":21,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
-{"type":"assistant/chunk","seq":22,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
+{"type":"reasoning-chunks","seq0":6,"time0":1784973850889,"data":{"turn":1,"step":1,"index":0,"dt":[199,1,0,0,0,18,1,0,0,0,0,27,0,1,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," reply"," with"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":23,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
-{"type":"assistant/chunk","seq":24,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":""}}}
-{"type":"assistant/chunk","seq":25,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"{"}}}
-{"type":"assistant/chunk","seq":26,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":27,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"command"}}}
-{"type":"assistant/chunk","seq":28,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":29,"time":1784973851245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":": "}}}
-{"type":"assistant/chunk","seq":30,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":31,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"echo"}}}
-{"type":"assistant/chunk","seq":32,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" WEB"}}}
-{"type":"assistant/chunk","seq":33,"time":1784973851272,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"_E"}}}
-{"type":"assistant/chunk","seq":34,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"2"}}}
-{"type":"assistant/chunk","seq":35,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"E"}}}
-{"type":"assistant/chunk","seq":36,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"_OK"}}}
-{"type":"assistant/chunk","seq":37,"time":1784973851300,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":38,"time":1784973851326,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":", "}}}
-{"type":"assistant/chunk","seq":39,"time":1784973851326,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":40,"time":1784973851352,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"description"}}}
-{"type":"assistant/chunk","seq":41,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":42,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":": "}}}
-{"type":"assistant/chunk","seq":43,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":44,"time":1784973851379,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"E"}}}
-{"type":"assistant/chunk","seq":45,"time":1784973851406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"cho"}}}
-{"type":"assistant/chunk","seq":46,"time":1784973851406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" the"}}}
-{"type":"assistant/chunk","seq":47,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" test"}}}
-{"type":"assistant/chunk","seq":48,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" string"}}}
-{"type":"assistant/chunk","seq":49,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":50,"time":1784973851461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"}"}}}
+{"type":"tool-call-chunks","seq0":24,"time0":1784973851217,"data":{"turn":1,"step":1,"index":1,"dt":[27,0,0,0,1,26,0,0,1,27,0,0,1,26,0,26,1,0,0,26,27,0,29,0,0,26],"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," WEB","_E","2","E","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," the"," test"," string","\"","}"]}}
{"type":"assistant/chunk","seq":51,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."}}}}
{"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}}
{"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}}
@@ -60,29 +18,7 @@
{"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"assistant/chunk","seq":61,"time":1784973852195,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
-{"type":"assistant/chunk","seq":62,"time":1784973852309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}
-{"type":"assistant/chunk","seq":63,"time":1784973852338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" executed"}}}
-{"type":"assistant/chunk","seq":64,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}}
-{"type":"assistant/chunk","seq":65,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
-{"type":"assistant/chunk","seq":66,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}}
-{"type":"assistant/chunk","seq":67,"time":1784973852370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
-{"type":"assistant/chunk","seq":68,"time":1784973852370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WEB"}}}
-{"type":"assistant/chunk","seq":69,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_E"}}}
-{"type":"assistant/chunk","seq":70,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}}
-{"type":"assistant/chunk","seq":71,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"E"}}}
-{"type":"assistant/chunk","seq":72,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}}
-{"type":"assistant/chunk","seq":73,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
-{"type":"assistant/chunk","seq":74,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
-{"type":"assistant/chunk","seq":75,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}}
-{"type":"assistant/chunk","seq":76,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}
-{"type":"assistant/chunk","seq":77,"time":1784973852428,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
-{"type":"assistant/chunk","seq":78,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
-{"type":"assistant/chunk","seq":79,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
-{"type":"assistant/chunk","seq":80,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
-{"type":"assistant/chunk","seq":81,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
-{"type":"assistant/chunk","seq":82,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
-{"type":"assistant/chunk","seq":83,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
+{"type":"reasoning-chunks","seq0":61,"time0":1784973852195,"data":{"turn":1,"step":2,"index":0,"dt":[114,29,1,0,0,31,0,1,0,0,0,27,0,0,0,30,1,0,0,0,0,30],"texts":["The"," command"," executed"," successfully"," and"," output"," \"","WEB","_E","2","E","_OK","\"."," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":84,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":85,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":86,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
index a6d1203d9d..7f2d8cf09f 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
+++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
@@ -19,13 +19,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
-- combobox "Plan mode":
- - option "Plan" [selected]
- - option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
-- combobox "Model":
- - option "DeepSeek-V4-Pro High" [selected]
- - option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
new file mode 100644
index 0000000000..f280e35fc6
--- /dev/null
+++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
@@ -0,0 +1,36 @@
+- button "Collapse sidebar":
+ - img
+- button "New session":
+ - img
+ - text: New Session
+- text: Workspaces
+- button "Group by":
+ - img
+- button "Create workspace":
+ - img
+- button "Search sessions":
+ - img
+- textbox "Search name, keywords..."
+- tree "Sessions":
+ - treeitem "workspace 1 session" [expanded]:
+ - img
+ - text: workspace 1 session
+ - treeitem "New Session now" [selected]
+- button "设置":
+ - img
+ - text: 设置
+- text: Let's start building
+- button "Choose workspace":
+ - img
+ - text: workspace
+ - img
+- textbox "Describe what you want to build"
+- button "Add attachment":
+ - img
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- button "Send message" [disabled]
+- text: 详情
+- button "关闭详情"
+- text: 点击消息流中的工具行查看详情
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
new file mode 100644
index 0000000000..1227617de5
--- /dev/null
+++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
@@ -0,0 +1,21 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Reply with the single word" [disabled]
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: Reply with the single word LIGHTHOUSE and stop.
+- button "Think The user wants me to reply with a single word. Let me comply.":
+ - img
+ - text: Think The user wants me to reply with a single word. Let me comply.
+- paragraph: LIGHTHOUSE
+- text: cache hit 99% · 7,810 tokens · 1 turns · 1 steps
+- textbox "Message the agent"
+- button "Add attachment":
+ - img
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl b/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl
new file mode 100644
index 0000000000..4d7caa325d
--- /dev/null
+++ b/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl
@@ -0,0 +1,17 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
+{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
+{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":6,"time0":1785015039930,"data":{"turn":1,"step":1,"index":0,"dt":[162,28,1,0,0,46,1,0,0,0,11,0,0,30],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}}
+{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"text-chunks","seq0":22,"time0":1785015040209,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,30,1],"texts":["L","IGH","TH","O","USE"]}}
+{"type":"assistant/chunk","seq":27,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}}
+{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
+{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
+{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
+{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
+{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
new file mode 100644
index 0000000000..c883524170
--- /dev/null
+++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
@@ -0,0 +1,18 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Reply with a one-sentence description" [disabled]
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: Reply with a one-sentence description of event sourcing, then stop.
+- paragraph: partial
+- text: 已停止 0 tokens · 1 turns · 1 steps
+- textbox "Message the agent"
+- button "Add attachment":
+ - img
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
new file mode 100644
index 0000000000..2a5ecc7b14
--- /dev/null
+++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
@@ -0,0 +1,16 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Reply with a one-sentence description" [disabled]
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: Reply with a one-sentence description of event sourcing, then stop.
+- textbox "Message the agent"
+- button "Add attachment":
+ - img
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md
new file mode 100644
index 0000000000..bfc7a2d267
--- /dev/null
+++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md
@@ -0,0 +1,21 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Reply with a one-sentence description" [disabled]
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: Reply with a one-sentence description of event sourcing, then stop.
+- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
+ - img
+ - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
+- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
+- text: cache hit 99% · 7,869 tokens · 1 turns · 1 steps
+- textbox "Message the agent"
+- button "Add attachment":
+ - img
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/live-interactions/session.jsonl b/apps/web/tests/snapshots/live-interactions/session.jsonl
new file mode 100644
index 0000000000..e002ec48ee
--- /dev/null
+++ b/apps/web/tests/snapshots/live-interactions/session.jsonl
@@ -0,0 +1,17 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784998084441,"cwd":"{{cwd}}/workspace"}
+{"type":"turn/start","seq":0,"time":1784998084454,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1784998084457,"data":{"title":"Reply with a one-sentence description","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1784998084519,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1784998084520,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
+{"type":"assistant/chunk","seq":5,"time":1784998084900,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":6,"time0":1784998084900,"data":{"turn":1,"step":1,"index":0,"dt":[153,3,0,29,1,0,0,28,0,1,0,0,0,28,0,0,0,29,1,0,29,0,0,29,0,0,36,0,21,29],"texts":["The"," user"," is"," asking"," for"," a"," one","-s","entence"," description"," of"," event"," sourcing","."," This"," is"," a"," straightforward"," knowledge"," question"," that"," doesn","'t"," require"," any"," skill"," loading"," or"," tool"," calls","."]}}
+{"type":"assistant/chunk","seq":37,"time":1784998085318,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"text-chunks","seq0":38,"time0":1784998085318,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,0,0,29,1,28,29,1,0,0,33,0,0,28,0,25,0,1,29,1,0,0,0,0,28,0,30,0,0,0,29,1,27,0,29,1,30,0,28,0,0,0,28,0,31],"texts":["Event"," sourcing"," is"," a"," pattern"," where"," all"," changes"," to"," an"," application","'s"," state"," are"," stored"," as"," an"," immutable",","," append","-only"," sequence"," of"," events",","," rather"," than"," pers","isting"," only"," the"," current"," state",","," enabling"," full"," audit","ability",","," temporal"," queries",","," and"," event","-driven"," architectures","."]}}
+{"type":"assistant/chunk","seq":85,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."}}}}
+{"type":"assistant/chunk","seq":86,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}}}}
+{"type":"assistant/chunk","seq":87,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}}}}
+{"type":"assistant/chunk","seq":88,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
+{"type":"step/end","seq":90,"time":1784998085820,"data":{"turn":1,"step":1}}
+{"type":"turn/end","seq":91,"time":1784998085821,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md
new file mode 100644
index 0000000000..d69a95eb2d
--- /dev/null
+++ b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md
@@ -0,0 +1,5 @@
+- text: bash
+- button "关闭详情"
+- text: Input
+- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
+- text: Output NAVIGATION_OK
diff --git a/apps/web/tests/snapshots/navigation-panes/seed.jsonl b/apps/web/tests/snapshots/navigation-panes/seed.jsonl
new file mode 100644
index 0000000000..72df45daac
--- /dev/null
+++ b/apps/web/tests/snapshots/navigation-panes/seed.jsonl
@@ -0,0 +1,54 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785011380476,"cwd":"{{cwd}}/workspace"}
+{"type":"turn/start","seq":0,"time":1785011380489,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1785011380492,"data":{"title":"NavScenario: first run bash to","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1785011380549,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1785011380550,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
+{"type":"assistant/chunk","seq":5,"time":1785011380917,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":6,"time0":1785011380917,"data":{"turn":1,"step":1,"index":0,"dt":[110,25,1,0,0,25,1,0,26,0,1,0,27,1,0,0,0,26,1,0,0,26,0,0,0,0,1,25,0,0,25,0,1,0,0,0,26,1,0,25,0,0,27,1,0,0,0,0,25,28,0,1,0,0,0,27,0,0,0,25,1,24,1,0,25],"texts":["The"," user"," wants"," me"," to"," follow"," a"," specific"," navigation"," scenario","."," Let"," me",":\n\n","1","."," Run"," bash"," to"," print"," \"","NA","V","IG","ATION","_OK","\"\n","2","."," Read"," nav","-a",".md"," and"," nav","-b",".md"," in"," two"," read"," calls"," in"," ONE"," message","\n","3","."," Reply"," with"," \"","FIR","ST","_D","ONE","\"\n\n","Let"," me"," start"," with"," the"," bash"," command"," and"," the"," reads","."]}}
+{"type":"assistant/chunk","seq":72,"time":1785011381556,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":73,"time0":1785011381557,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,26,0,0,0,25,1,26,0,0,1,33,1,17,0,0,0,28,1,0,0,0,24,1],"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," NAV","IG","ATION","_OK","\"",", ","\"","description","\"",": ","\"","Print"," NAV","IG","ATION","_OK","\"","}"]}}
+{"type":"assistant/chunk","seq":99,"time":1785011381793,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":100,"time0":1785011381793,"data":{"turn":1,"step":1,"index":2,"dt":[26,0,0,0,1,27,0,0,0,26,1,23],"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","args":["","{","\"","file","_path","\"",": ","\"","nav","-a",".md","\"","}"]}}
+{"type":"assistant/chunk","seq":113,"time":1785011381924,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":3,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":114,"time0":1785011381924,"data":{"turn":1,"step":1,"index":3,"dt":[26,1,0,26,0,0,0,26,0,0,0,26],"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","args":["","{","\"","file","_path","\"",": ","\"","nav","-b",".md","\"","}"]}}
+{"type":"assistant/chunk","seq":127,"time":1785011382086,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."}}}}
+{"type":"assistant/chunk","seq":128,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"}}}}
+{"type":"assistant/chunk","seq":129,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"}}}}
+{"type":"assistant/chunk","seq":130,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":3,"block":{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}}}
+{"type":"assistant/chunk","seq":131,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}}}}
+{"type":"assistant/chunk","seq":132,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"}
+{"type":"tool/call","seq":134,"time":1785011382092,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"}}
+{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false},"sourceEventSeqs":[134],"surfaceOp":"append"}
+{"type":"tool/call","seq":136,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"}}
+{"type":"tool/call","seq":137,"time":1785011382106,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}
+{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"{{cwd}}/workspace/nav-a.md\nfile\n\n1: # alpha nav\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[136],"surfaceOp":"append"}
+{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"{{cwd}}/workspace/nav-b.md\nfile\n\n1: # beta nav\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[137],"surfaceOp":"append"}
+{"type":"step/end","seq":140,"time":1785011382117,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":141,"time":1785011382118,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":142,"time":1785011382673,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":143,"time0":1785011382673,"data":{"turn":1,"step":2,"index":0,"dt":[90,27,27,1,0,0,0,26,1,0,0,0,0,27,0,0,0,0,27,0,0,27,0,0,0,0,1,25,1,0,0,0,0,26,1,0,0,0,0,25,1,0,26,1,0,0,0,0,26,1],"texts":["All"," three"," calls"," succeeded",":\n","1","."," bash"," printed"," \"","NA","V","IG","ATION","_OK","\"\n","2","."," nav","-a",".md"," contains"," \"#"," alpha"," nav","\"\n","3","."," nav","-b",".md"," contains"," \"#"," beta"," nav","\"\n\n","Now"," I"," need"," to"," reply"," with"," the"," single"," word"," \"","FIR","ST","_D","ONE","\"."]}}
+{"type":"assistant/chunk","seq":194,"time":1785011383060,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"text-chunks","seq0":195,"time0":1785011383060,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,29],"texts":["FIR","ST","_D","ONE"]}}
+{"type":"assistant/chunk","seq":199,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."}}}}
+{"type":"assistant/chunk","seq":200,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST_DONE"}}}}
+{"type":"assistant/chunk","seq":201,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}}}}
+{"type":"assistant/chunk","seq":202,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"}
+{"type":"step/end","seq":204,"time":1785011383091,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":205,"time":1785011383092,"data":{"turn":1,"reason":{"kind":"completed"}}}
+{"type":"turn/start","seq":206,"time":1785011383106,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"step/start","seq":208,"time":1785011383107,"data":{"turn":2,"step":1}}
+{"type":"assistant/chunk","seq":209,"time":1785011383497,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":210,"time0":1785011383497,"data":{"turn":2,"step":1,"index":0,"dt":[125,23,1,0,0,88,0,0,5,0,1,0,0,7,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," specific"," format","."," Let"," me"," do"," that","."]}}
+{"type":"assistant/chunk","seq":226,"time":1785011383748,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"text-chunks","seq0":227,"time0":1785011383748,"data":{"turn":2,"step":1,"index":1,"dt":[24,1,25,0,0,25,26,1,0,0,0,25,0,0,0,1,26,1],"texts":["##"," Navigation"," Summary","\n\n","-"," alpha"," nav","\n","-"," beta"," nav","\n\n","```\n","echo"," WATER","F","ALL","\n","```"]}}
+{"type":"assistant/chunk","seq":246,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."}}}}
+{"type":"assistant/chunk","seq":247,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}}}}
+{"type":"assistant/chunk","seq":248,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}}}}
+{"type":"assistant/chunk","seq":249,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"}
+{"type":"step/end","seq":251,"time":1785011383904,"data":{"turn":2,"step":1}}
+{"type":"turn/end","seq":252,"time":1785011383904,"data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md
new file mode 100644
index 0000000000..80d6f161ca
--- /dev/null
+++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md
@@ -0,0 +1 @@
+- text: "Turn 1 Message {{duration}} #1 User NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. +{{duration}} Step 1 {{duration}} bash read×2 #2 Tool bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} +{{duration}} #3 Tool read · {\"file_path\": \"nav-a.md\"} +{{duration}} #4 Tool read · {\"file_path\": \"nav-b.md\"} +{{duration}} Step 2 {{duration}} #5 Message FIRST_DONE 349 56 51 +{{duration}} Turn 2 Message {{duration}} #6 User Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. +{{duration}} Step 1 {{duration}} #7 Message ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ``` 141 36 16 +{{duration}}"
diff --git a/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md b/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md
new file mode 100644
index 0000000000..6c5ab1a046
--- /dev/null
+++ b/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md
@@ -0,0 +1 @@
+- text: 3 turns · 3 steps · 3 tool calls turn 0 turn 1 turn 2
diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md
new file mode 100644
index 0000000000..3594459176
--- /dev/null
+++ b/apps/web/tests/snapshots/question-composer/answered.expected.md
@@ -0,0 +1,33 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Use the ask_user_question tool to" [disabled]
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- button "编辑":
+ - img
+- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
+ - img
+ - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
+- button:
+ - img
+- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"
+- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
+ - img
+ - text: Think The user answered "Blue". I should now reply with the single word DONE and stop.
+- paragraph: DONE
+- text: cache hit 95% · 8,769 tokens · 1 turns · 2 steps
+- textbox "Message the agent"
+- button "Add attachment":
+ - img
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl
new file mode 100644
index 0000000000..a98f2a92f2
--- /dev/null
+++ b/apps/web/tests/snapshots/question-composer/session.jsonl
@@ -0,0 +1,31 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"}
+{"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}}
+{"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
+{"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}}
+{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
+{"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}}
+{"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
+{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
+{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
+{"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":137,"time0":1785150170606,"data":{"turn":1,"step":2,"index":0,"dt":[111,29,0,0,0,1,34,0,0,17,1,29,0,0,0,0,1,26],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," should"," now"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}}
+{"type":"assistant/chunk","seq":156,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":157,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
+{"type":"assistant/chunk","seq":158,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":159,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."}}}}
+{"type":"assistant/chunk","seq":160,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","seq":161,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}}}}
+{"type":"assistant/chunk","seq":162,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"}
+{"type":"step/end","seq":164,"time":1785150170858,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":165,"time":1785150170858,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md
new file mode 100644
index 0000000000..934ec244d5
--- /dev/null
+++ b/apps/web/tests/snapshots/question-composer/ui.expected.md
@@ -0,0 +1,23 @@
+- region "Which color do you prefer?":
+ - text: Pick one
+ - heading "Which color do you prefer?" [level=2]
+ - text: 1 / 1
+ - button "上一题" [disabled]:
+ - img
+ - button "下一题" [disabled]:
+ - img
+ - button "放弃整组问题":
+ - img
+ - radiogroup:
+ - radio "Blue":
+ - text: 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
+ - img
+ - radio "Green":
+ - text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
+ - img
+ - button "其他,请填写自定义答案":
+ - img
+ - text: 其他,请填写自定义答案
+ - status
+ - button "跳过本题"
+ - button "提交" [disabled]
diff --git a/apps/web/tests/snapshots/seeded-history/seed.jsonl b/apps/web/tests/snapshots/seeded-history/seed.jsonl
index 27e31004bc..abf7a61162 100644
--- a/apps/web/tests/snapshots/seeded-history/seed.jsonl
+++ b/apps/web/tests/snapshots/seeded-history/seed.jsonl
@@ -5,59 +5,11 @@
{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784974101296,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"assistant/chunk","seq":6,"time":1784974101297,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
-{"type":"assistant/chunk","seq":7,"time":1784974101422,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
-{"type":"assistant/chunk","seq":8,"time":1784974101452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
-{"type":"assistant/chunk","seq":9,"time":1784974101452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
-{"type":"assistant/chunk","seq":10,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
-{"type":"assistant/chunk","seq":11,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
-{"type":"assistant/chunk","seq":12,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
-{"type":"assistant/chunk","seq":13,"time":1784974101483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
-{"type":"assistant/chunk","seq":14,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
-{"type":"assistant/chunk","seq":15,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}}
-{"type":"assistant/chunk","seq":16,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
-{"type":"assistant/chunk","seq":17,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
-{"type":"assistant/chunk","seq":18,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
-{"type":"assistant/chunk","seq":19,"time":1784974101514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
-{"type":"assistant/chunk","seq":20,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
-{"type":"assistant/chunk","seq":21,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
-{"type":"assistant/chunk","seq":22,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
-{"type":"assistant/chunk","seq":23,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
-{"type":"assistant/chunk","seq":24,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
-{"type":"assistant/chunk","seq":25,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}}
-{"type":"assistant/chunk","seq":26,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
-{"type":"assistant/chunk","seq":27,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}}
-{"type":"assistant/chunk","seq":28,"time":1784974101546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}}
-{"type":"assistant/chunk","seq":29,"time":1784974101576,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}}
-{"type":"assistant/chunk","seq":30,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}}
-{"type":"assistant/chunk","seq":31,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parallel"}}}
-{"type":"assistant/chunk","seq":32,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
+{"type":"reasoning-chunks","seq0":6,"time0":1784974101297,"data":{"turn":1,"step":1,"index":0,"dt":[125,30,0,1,0,0,30,1,0,0,0,0,30,1,0,0,30,0,0,0,0,1,30,1,0,0],"texts":["The"," user"," wants"," me"," to"," read"," a",".txt"," and"," b",".txt",","," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," do"," both"," reads"," in"," parallel","."]}}
{"type":"assistant/chunk","seq":33,"time":1784974101666,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
-{"type":"assistant/chunk","seq":34,"time":1784974101667,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":""}}}
-{"type":"assistant/chunk","seq":35,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"{"}}}
-{"type":"assistant/chunk","seq":36,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":37,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"file"}}}
-{"type":"assistant/chunk","seq":38,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"_path"}}}
-{"type":"assistant/chunk","seq":39,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":40,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":": "}}}
-{"type":"assistant/chunk","seq":41,"time":1784974101726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":42,"time":1784974101727,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"a"}}}
-{"type":"assistant/chunk","seq":43,"time":1784974101727,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":".txt"}}}
-{"type":"assistant/chunk","seq":44,"time":1784974101756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":45,"time":1784974101757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"}"}}}
+{"type":"tool-call-chunks","seq0":34,"time0":1784974101667,"data":{"turn":1,"step":1,"index":1,"dt":[30,0,0,0,0,0,29,1,0,29,1],"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","args":["","{","\"","file","_path","\"",": ","\"","a",".txt","\"","}"]}}
{"type":"assistant/chunk","seq":46,"time":1784974101821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}}
-{"type":"assistant/chunk","seq":47,"time":1784974101822,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":""}}}
-{"type":"assistant/chunk","seq":48,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"{"}}}
-{"type":"assistant/chunk","seq":49,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":50,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"file"}}}
-{"type":"assistant/chunk","seq":51,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"_path"}}}
-{"type":"assistant/chunk","seq":52,"time":1784974101850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":53,"time":1784974101881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":": "}}}
-{"type":"assistant/chunk","seq":54,"time":1784974101881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":55,"time":1784974101882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"b"}}}
-{"type":"assistant/chunk","seq":56,"time":1784974101882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":".txt"}}}
-{"type":"assistant/chunk","seq":57,"time":1784974101908,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
-{"type":"assistant/chunk","seq":58,"time":1784974101909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"}"}}}
+{"type":"tool-call-chunks","seq0":47,"time0":1784974101822,"data":{"turn":1,"step":1,"index":2,"dt":[27,0,0,0,1,31,0,1,0,26,1],"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","args":["","{","\"","file","_path","\"",": ","\"","b",".txt","\"","}"]}}
{"type":"assistant/chunk","seq":59,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."}}}}
{"type":"assistant/chunk","seq":60,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}}
{"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}}
@@ -71,35 +23,7 @@
{"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"assistant/chunk","seq":72,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}}
-{"type":"assistant/chunk","seq":73,"time":1784974102505,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}}
-{"type":"assistant/chunk","seq":74,"time":1784974102534,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}}
-{"type":"assistant/chunk","seq":75,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}}
-{"type":"assistant/chunk","seq":76,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
-{"type":"assistant/chunk","seq":77,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
-{"type":"assistant/chunk","seq":78,"time":1784974102565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
-{"type":"assistant/chunk","seq":79,"time":1784974102595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
-{"type":"assistant/chunk","seq":80,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
-{"type":"assistant/chunk","seq":81,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
-{"type":"assistant/chunk","seq":82,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"alpha"}}}
-{"type":"assistant/chunk","seq":83,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
-{"type":"assistant/chunk","seq":84,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
-{"type":"assistant/chunk","seq":85,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}}
-{"type":"assistant/chunk","seq":86,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
-{"type":"assistant/chunk","seq":87,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
-{"type":"assistant/chunk","seq":88,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
-{"type":"assistant/chunk","seq":89,"time":1784974102626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"beta"}}}
-{"type":"assistant/chunk","seq":90,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
-{"type":"assistant/chunk","seq":91,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
-{"type":"assistant/chunk","seq":92,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}}
-{"type":"assistant/chunk","seq":93,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}}
-{"type":"assistant/chunk","seq":94,"time":1784974102689,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
-{"type":"assistant/chunk","seq":95,"time":1784974102690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
-{"type":"assistant/chunk","seq":96,"time":1784974102690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}}
-{"type":"assistant/chunk","seq":97,"time":1784974102716,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
-{"type":"assistant/chunk","seq":98,"time":1784974102717,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}}
-{"type":"assistant/chunk","seq":99,"time":1784974102748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}}
-{"type":"assistant/chunk","seq":100,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
+{"type":"reasoning-chunks","seq0":72,"time0":1784974102397,"data":{"turn":1,"step":2,"index":0,"dt":[108,29,1,0,0,30,30,1,0,0,0,29,0,0,0,0,1,30,0,0,0,33,1,0,26,1,31,1],"texts":["Both"," files"," have"," been"," read","."," a",".txt"," contains"," \"","alpha","\""," and"," b",".txt"," contains"," \"","beta","\"."," I","'ll"," now"," reply"," with"," D","ONE"," as"," instructed","."]}}
{"type":"assistant/chunk","seq":101,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":102,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":103,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
index c919fccec1..3e642bafa1 100644
--- a/apps/web/tests/snapshots/seeded-history/ui.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -24,13 +24,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
-- combobox "Plan mode":
- - option "Plan" [selected]
- - option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
-- combobox "Model":
- - option "DeepSeek-V4-Pro High" [selected]
- - option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md
new file mode 100644
index 0000000000..75959994f1
--- /dev/null
+++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md
@@ -0,0 +1,30 @@
+- dialog "设置":
+ - navigation:
+ - text: 设置
+ - button "通用设置":
+ - img
+ - text: 通用设置
+ - button "模型":
+ - img
+ - text: 模型
+ - button "关闭":
+ - img
+ - text: 关闭
+ - text: 权限 选择默认权限模式
+ - button "Read only" [disabled]:
+ - text: Read only
+ - img
+ - text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言
+ - button "中文":
+ - text: 中文
+ - img
+ - text: 外观
+ - button "浅色":
+ - img
+ - text: 浅色
+ - button "深色":
+ - img
+ - text: 深色
+ - button "跟随系统" [pressed]:
+ - img
+ - text: 跟随系统
diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md
new file mode 100644
index 0000000000..a26bbb7bd8
--- /dev/null
+++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md
@@ -0,0 +1,39 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Use the ask_user_question tool to" [disabled]
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
+- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
+ - img
+ - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
+- button
+- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)"
+- button "▸ 问题内容"
+- text: 请在原客户端处理(web 端作答后续里程碑提供) cache hit 98% · 7,946 tokens · 1 turns · 1 steps
+- region "Ready to continue?":
+ - text: Checkpoint
+ - heading "Ready to continue?" [level=2]
+ - text: 1 / 1
+ - button "上一题" [disabled]:
+ - img
+ - button "下一题" [disabled]:
+ - img
+ - button "放弃整组问题":
+ - img
+ - radiogroup:
+ - radio "Yes":
+ - text: 1 Yes
+ - img
+ - radio "No":
+ - text: 2 No
+ - img
+ - button "其他,请填写自定义答案":
+ - img
+ - text: 其他,请填写自定义答案
+ - status
+ - button "跳过本题"
+ - button "提交" [disabled]
diff --git a/apps/web/tests/snapshots/steering/session.jsonl b/apps/web/tests/snapshots/steering/session.jsonl
new file mode 100644
index 0000000000..4015fa4ab8
--- /dev/null
+++ b/apps/web/tests/snapshots/steering/session.jsonl
@@ -0,0 +1,31 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785004180013,"cwd":"{{cwd}}/workspace"}
+{"type":"turn/start","seq":0,"time":1785004180030,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":1,"time":1785004180030,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1785004180033,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1785004180105,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1785004180106,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
+{"type":"assistant/chunk","seq":5,"time":1785004180696,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":6,"time0":1785004180697,"data":{"turn":1,"step":1,"index":0,"dt":[88,29,1,0,0,0,28,0,0,1,0,0,30,1,27,0,0,0,0,28,1,0,30,0,0,0,28,1],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," to"," ask"," them"," a"," specific"," question"," with"," the"," given"," parameters","."," Let"," me"," do"," exactly"," that","."]}}
+{"type":"assistant/chunk","seq":35,"time":1785004181077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":36,"time0":1785004181078,"data":{"turn":1,"step":1,"index":1,"dt":[27,1,0,0,0,28,1,0,0,29,1,0,0,0,0,28,1,0,0,0,0,29,0,0,0,0,1,28,0,0,0,0,1,28,0,0,0,0,0,28,0,1,0,0,0,28,30],"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","check","point","\","," \"","question","\":"," \"","Ready"," to"," continue","?\","," \"","header","\":"," \"","Check","point","\","," \"","options","\":"," [","{\"","label","\":"," \"","Yes","\"},"," {\"","label","\":"," \"","No","\"","}]","}]","}"]}}
+{"type":"assistant/chunk","seq":84,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."}}}}
+{"type":"assistant/chunk","seq":85,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}}}
+{"type":"assistant/chunk","seq":86,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}}}}
+{"type":"assistant/chunk","seq":87,"time":1785004181402,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"}
+{"type":"tool/call","seq":89,"time":1785004181407,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}
+{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"}
+{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"step/end","seq":92,"time":1785004181870,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":93,"time":1785004181870,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":94,"time":1785004182322,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":95,"time0":1785004182323,"data":{"turn":1,"step":2,"index":0,"dt":[129,28,1,0,0,28,1,29,0,0,30,1,27,0,0,0,29,1,0,0,0,0,28,1,0,29,64,0],"texts":["The"," user"," selected"," \"","Yes","\""," and"," wants"," me"," to"," include"," the"," word"," \"","B","AN","ANA","\""," in"," my"," final"," reply","."," Let"," me"," acknowledge"," their"," answer","."]}}
+{"type":"assistant/chunk","seq":124,"time":1785004182750,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"text-chunks","seq0":125,"time0":1785004182750,"data":{"turn":1,"step":2,"index":1,"dt":[24,0,28,2,0,27,31,1,0,0],"texts":["Great",","," let","'s"," move"," forward","."," B","AN","ANA","!"]}}
+{"type":"assistant/chunk","seq":136,"time":1785004182892,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."}}}}
+{"type":"assistant/chunk","seq":137,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Great, let's move forward. BANANA!"}}}}
+{"type":"assistant/chunk","seq":138,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}}}}
+{"type":"assistant/chunk","seq":139,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":140,"time":1785004182894,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."},{"type":"text","text":"Great, let's move forward. BANANA!"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139],"surfaceOp":"append"}
+{"type":"step/end","seq":141,"time":1785004182895,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":142,"time":1785004182895,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md
new file mode 100644
index 0000000000..a887bad8eb
--- /dev/null
+++ b/apps/web/tests/snapshots/steering/settled.expected.md
@@ -0,0 +1,27 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Use the ask_user_question tool to" [disabled]
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
+- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
+ - img
+ - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
+- button:
+ - img
+- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply."
+- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
+ - img
+ - text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
+- paragraph: Great, let's move forward. BANANA!
+- text: cache hit 98% · 15,967 tokens · 1 turns · 2 steps
+- textbox "Message the agent"
+- button "Add attachment":
+ - img
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/workspace-management/.gitkeep b/apps/web/tests/snapshots/workspace-management/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts
new file mode 100644
index 0000000000..dc1bc657ad
--- /dev/null
+++ b/apps/web/tests/steering.e2e.ts
@@ -0,0 +1,173 @@
+// Web e2e scenario: mid-turn steering, end to end. The composer locks while a
+// turn runs, so the product UI has no steering gesture yet — the steer is
+// POSTed from the page itself over the same same-origin /api transport the
+// client uses (TODO(web-steer-composer): drive this through a composer
+// gesture once one exists). Everything downstream is product: the gateway
+// routes mode:'steer' to Agent.steer, the loop drains it at the step
+// boundary into a durable steering/message event, the SSE mux pushes it, and
+// the transcript renders the badged interjection bubble. The question
+// composer supplies the deterministic mid-turn window: while ask_user_question
+// blocks, the turn is provably running, so record and replay perform the
+// identical steer-then-answer sequence with zero timing dependence — and the
+// recorded final reply proves the steer reached the MODEL (it obeys an
+// instruction that only the steering message carries).
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+// Two goldens for the two distinct states this interaction produces: the
+// mid-turn moment (steer ACCEPTED but deliberately invisible — the loop
+// drains steering at the step boundary, so no interjection bubble exists
+// while the question still blocks the step) and the settled transcript
+// (badged bubble in place, final reply obeying it). The pair pins the
+// timing semantics visually: if the client ever starts rendering pending
+// steers eagerly, the mid-steer golden flips first.
+const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
+const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
+const MODE = webSnapshotMode()
+
+const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.'
+const STEER = 'Interjection: include the word BANANA in your final reply.'
+
+/** Concatenated assistant text deltas — the model-visible reply body. */
+function assistantText(events: SessionEvent[]): string {
+ return events
+ .filter(e => e.type === 'assistant/chunk')
+ .map((e) => {
+ const chunk = (e as SessionEvent & { data: { chunk: { type: string; text?: string } } }).data.chunk
+ return chunk.type === 'text-delta' ? chunk.text ?? '' : ''
+ })
+ .join('')
+}
+
+describe('web e2e: mid-turn steering lands durably and visibly', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ let liveSessionId: string | undefined
+ const sessionEvents: SessionEvent[] = []
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
+ scaffold.ctx.on('session/event', (session, event) => {
+ liveSessionId ??= session.id
+ sessionEvents.push(event)
+ })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ // Fresh world: connect a Workspace so the composer scenarios start live.
+ await connectFreshWorkspace(page)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('steers during the blocked step; the interjection is logged, rendered, and obeyed', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-steering'))
+ if (MODE !== 'record') {
+ // The steer must NOT be a user/message — it lands as steering/message.
+ expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+ }
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
+ await input.fill(PROMPT)
+ await input.press('Enter')
+
+ // The blocked composer is the mid-turn barrier: its presence proves the
+ // ask_user_question step is executing, i.e. the turn is running NOW.
+ const composer = page.locator('[data-question-key]')
+ await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
+
+ // Steer through the real wire from the page (same envelope + endpoint the
+ // web client's session.prompt uses). accepted:true is the transport proof.
+ expect(liveSessionId).toBeDefined()
+ const reply = await page.evaluate(async ({ sessionId, text }) => {
+ const response = await fetch('/api/session.prompt', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ type: 'client-request',
+ rpcId: crypto.randomUUID(),
+ method: 'session.prompt',
+ payload: { sessionId, mode: 'steer', content: [{ type: 'text', text }] },
+ }),
+ })
+ return await response.json() as { result?: { ok?: boolean } }
+ }, { sessionId: liveSessionId!, text: STEER })
+ expect(reply.result?.ok).toBe(true)
+
+ if (MODE !== 'record') {
+ // Mid-turn golden: the ACCEPTED steer is durable in the inbox but the
+ // loop drains steering only at the step boundary, so no steering/message
+ // exists yet and no interjection bubble renders — the composer still
+ // blocks, alone. The DOM is stable here (no further SSE frames can
+ // arrive until the question is answered), making this state capturable.
+ expect(await page.getByText('插话').count()).toBe(0)
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE)
+ }
+
+ // Answer the composer; the tool result closes the step, the loop drains
+ // the steer as steering/message, and the steered continuation runs the
+ // final model call.
+ await composer.getByRole('radio', { name: 'Yes' }).click()
+ await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
+ await settled
+
+ if (MODE === 'record') {
+ const sessionId = await settled
+ await recordFixture(scaffold, sessionId, FIXTURE)
+ // Fixture honesty: a recording where the live model ignored the steer
+ // would replay as a vacuous scenario — reject it and re-record instead.
+ const recorded = parseSessionLog(await readFile(FIXTURE, 'utf8'))
+ expect(recorded.filter(e => e.type === 'steering/message')).toHaveLength(1)
+ expect(assistantText(recorded)).toContain('BANANA')
+ return
+ }
+
+ // Durable: exactly one steering/message, inside turn 1, carrying the text.
+ const steerEvents = sessionEvents.filter(e => e.type === 'steering/message')
+ expect(steerEvents).toHaveLength(1)
+ expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1)
+ expect(JSON.stringify(steerEvents[0])).toContain('BANANA')
+ const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
+ expect(turnEnds).toHaveLength(1)
+ expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
+
+ // Visible: the badged interjection bubble plus the reply that obeys it
+ // (steer text + final reply each contain the marker word).
+ await expect.poll(() => page.getByText('插话').count(), { timeout: 15_000 }).toBe(1)
+ await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
+ await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
+ expect(await page.locator('[data-question-key]').count()).toBe(0)
+ // Settled golden: badge + interjection between the question round trip
+ // and the obeying reply, composer takeover gone.
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 200_000)
+
+ it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+ await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md'])
+ })
+})
diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts
index ce0a6db799..5521d984da 100644
--- a/apps/web/tests/support.ts
+++ b/apps/web/tests/support.ts
@@ -32,6 +32,30 @@ export function probeFreePort(): Promise {
})
}
+/**
+ * Drive the hero's workspace picker through its create-by-name dialog until
+ * the live composer unlocks. A fresh world has no Workspace, so the boot
+ * lands in the locked view state (startup auto-selection has nothing to
+ * select); every scenario that types into the composer must connect one
+ * first. The default name 'workspace' keeps the session header cwd at
+ * /workspace — the materialization proof several scenarios
+ * assert.
+ * @param page - the page under test.
+ * @param name - workspace name typed into the create dialog.
+ */
+export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise {
+ await page.getByRole('button', { name: 'Choose workspace' }).click()
+ await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
+ await dialog.waitFor({ timeout: 10_000 })
+ await dialog.getByLabel('New workspace name').fill(name)
+ await dialog.getByRole('button', { name: 'Create workspace' }).click()
+ // The pick connected the workspace: the blank session's live composer
+ // replaces the locked placeholder and enables.
+ await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
+ .waitFor({ timeout: 15_000 })
+}
+
/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
export async function saveFailureShot(page: Page, name: string): Promise {
const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts
new file mode 100644
index 0000000000..ef710129d8
--- /dev/null
+++ b/apps/web/tests/todo-display.snapshot.ts
@@ -0,0 +1,191 @@
+// @vitest-environment jsdom
+// Todo display snapshot over the BUILT client graph (the code-mode-fixture
+// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
+// Opens the fixture history session and pins the todo_write turn's two
+// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary
+// derived from the call args) and the TodoPanel plan strip riding the
+// 'conversation.input.dock' slot (fed by ConversationSnapshot.todos, seeded
+// by the tail history page), including the collapse interaction.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
+ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+ { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+ { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ {
+ id: '@deepseek-ai/dsh-client-ui-workspace',
+ dir: 'ui-workspace',
+ url: '/plugins/ui-workspace.js',
+ rev: 'fx',
+ inject: [
+ '@deepseek-ai/dsh-client-runtime',
+ '@deepseek-ai/dsh-client-ui-conversation',
+ '@deepseek-ai/dsh-client-ui-sidebar',
+ ],
+ },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+ plugin.url,
+ readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+ __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+ __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+ observe(): void {}
+ disconnect(): void {}
+ unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+beforeEach(() => {
+ localStorage.clear()
+ document.title = 'DeepSeek Harness'
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+ setTimeout(() => { callback(0) }, 0) as unknown as number)
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+})
+
+afterEach(() => {
+ act(() => { unmount?.() })
+ unmount = undefined
+ cleanup()
+ delete win.__DSH_BOOT__
+ delete win.__ModuleLoader__
+ delete (globalThis as Record).__fxTiming
+ document.body.innerHTML = ''
+ document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+ document.title = ''
+ history.replaceState(null, '', '/')
+ vi.unstubAllGlobals()
+})
+
+/** Boot the complete built client graph against the populated fixture branch. */
+function boot(): void {
+ history.replaceState(null, '', '/?fixture')
+ const root = document.createElement('div')
+ root.id = 'root'
+ document.body.appendChild(root)
+ win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
+ act(() => {
+ const entry = new AppWebEntry(root, {
+ fetchBundle: (url) => {
+ const code = bundles.get(url)
+ return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
+ },
+ executeBundle: (code) => { (0, eval)(code) },
+ })
+ void entry.run()
+ unmount = () => { entry.dispose() }
+ })
+}
+
+/** Collapse decorative whitespace while preserving the text a user sees. */
+function visibleText(element: Element): string {
+ return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
+}
+
+/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */
+async function openFixtureSession(): Promise {
+ const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+ // Anchor on the expandable Workspace group row: the title and the blank
+ // session row can both read "fixture", and the session-count meta shifts
+ // when a blank session joins the group.
+ const group = (await within(tree).findAllByText('fixture'))
+ .map(el => el.closest('[role="treeitem"]'))
+ .find(el => el?.getAttribute('aria-expanded') !== null)
+ if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
+ if (group.getAttribute('aria-expanded') === 'false') {
+ fireEvent.click(within(group).getByText('fixture'))
+ await waitFor(() => {
+ expect(group.getAttribute('aria-expanded')).toBe('true')
+ })
+ }
+ const session = await within(tree).findByText('Fixture 历史会话')
+ fireEvent.click(session)
+ await waitFor(() => {
+ expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull()
+ }, { timeout: 10_000 })
+}
+
+it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => {
+ boot()
+ await openFixtureSession()
+
+ const row = document.querySelector('[data-sample="todo-row"]')
+ if (row === null) throw new Error('todo row missing')
+ const panel = document.querySelector('[data-testid="todo-panel"]')
+ if (panel === null) throw new Error('todo panel missing from the input dock')
+
+ // Header spans are adjacent inline nodes; textContent joins "To-dos" +
+ // "1/3…" with no space (visual gap is CSS gap: 10px, not a text node).
+ expect({
+ row: visibleText(row),
+ rowState: row.getAttribute('data-state'),
+ panelHeader: visibleText(panel.querySelector('button') ?? panel),
+ panelItems: [...panel.querySelectorAll('li')].map(item => ({
+ status: item.getAttribute('data-status'),
+ text: visibleText(item),
+ })),
+ }).toMatchInlineSnapshot(`
+ {
+ "panelHeader": "To-dos1/3 tasks · 1 in progress",
+ "panelItems": [
+ {
+ "status": "completed",
+ "text": "梳理需求",
+ },
+ {
+ "status": "in_progress",
+ "text": "实现 fixture 样本",
+ },
+ {
+ "status": "pending",
+ "text": "浏览器验收",
+ },
+ ],
+ "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本",
+ "rowState": "ok",
+ }
+ `)
+})
+
+it('collapses the plan strip to the count summary and restores it', async () => {
+ boot()
+ await openFixtureSession()
+
+ const panel = document.querySelector('[data-testid="todo-panel"]')
+ if (panel === null) throw new Error('todo panel missing from the input dock')
+ const header = panel.querySelector('button')
+ if (header === null) throw new Error('todo panel header missing')
+
+ fireEvent.click(header)
+ expect({
+ collapsedHeader: visibleText(header),
+ listGone: panel.querySelector('ul') === null,
+ }).toMatchInlineSnapshot(`
+ {
+ "collapsedHeader": "To-dos1/3 tasks · 1 in progress",
+ "listGone": true,
+ }
+ `)
+
+ fireEvent.click(header)
+ expect(panel.querySelectorAll('li')).toHaveLength(3)
+})
diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts
index 78ac843a64..e1fe183ef1 100644
--- a/apps/web/tests/workspace-flow.snapshot.ts
+++ b/apps/web/tests/workspace-flow.snapshot.ts
@@ -1,4 +1,12 @@
// @vitest-environment jsdom
+// Assembled keyless snapshots of the New Session flow under the agent-parity
+// model: startup auto-connects the recent Workspace's blank session when one
+// exists; without any Workspace the composer is locked in the pure view
+// state until one is chosen. Picking one materializes the full Session+Agent
+// (reuse-or-create of the workspace's blank session), the first ACCEPTED
+// prompt flips blank and surfaces the session in lists, and failures leave
+// no client-side transaction state: a failed attach keeps the view state
+// locked, a rejected prompt keeps the session blank with the draft restored.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -10,9 +18,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
- { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
+ { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
+ { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
@@ -90,25 +101,12 @@ function boot(search: string): void {
})
}
-/** Recreate the built client graph while preserving browser-persistent state. */
-function refresh(search: string): void {
- act(() => { unmount?.() })
- unmount = undefined
- cleanup()
- delete win.__DSH_BOOT__
- delete win.__ModuleLoader__
- delete (globalThis as Record).__fxTiming
- document.body.innerHTML = ''
- document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
- boot(search)
-}
-
/** Collapse decorative whitespace while preserving the text a user sees. */
function visibleText(element: Element): string {
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
}
-/** Identify the interactive Workspace chip by its menu contract. */
+/** Identify the interactive Workspace chip (view state or blank-session hero) by its menu contract. */
function workspaceChip(): HTMLElement {
const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
.find(element => element.getAttribute('aria-haspopup') === 'menu')
@@ -116,208 +114,241 @@ function workspaceChip(): HTMLElement {
return chip
}
-/** Wait for the runtime-owned controlled input to echo a browser edit. */
-async function setComposerText(composer: HTMLElement, value: string): Promise {
- fireEvent.change(composer, { target: { value } })
- await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) })
+/** The locked view-state composer (no session yet). */
+async function findLockedComposer(): Promise {
+ return await screen.findByPlaceholderText(
+ 'Choose a workspace to start', {}, { timeout: 10_000 },
+ )
}
-it('starts a writable page-local draft without inventing a sidebar Workspace', async () => {
+/** The live blank-session hero composer (session materialized). */
+async function findHeroComposer(): Promise {
+ return await screen.findByPlaceholderText(
+ 'Describe what you want to build', {}, { timeout: 10_000 },
+ )
+}
+
+/** Edit the machine-owned controlled input and assert the same-tick echo. */
+function setComposerText(composer: HTMLElement, value: string): void {
+ fireEvent.change(composer, { target: { value } })
+ expect((composer as HTMLTextAreaElement).value).toBe(value)
+}
+
+/** Drive the picker's create flow: chip → Create a new workspace → name dialog. */
+async function createWorkspaceViaPicker(name: string): Promise {
+ fireEvent.click(workspaceChip())
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
+ const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
+ fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
+ target: { value: name },
+ })
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
+}
+
+/** Pick an existing Workspace row from the chip menu. */
+async function pickWorkspace(title: string): Promise {
+ fireEvent.click(workspaceChip())
+ fireEvent.click(await screen.findByRole('menuitem', { name: title }))
+}
+
+it('locks the composer in the New Session view state until a Workspace is chosen', async () => {
boot('?fixture=empty')
- const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ const composer = await findLockedComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
- await setComposerText(composer, 'keep this local')
expect({
headline: visibleText(screen.getByText("Let's start building")),
- workspaceDraft: visibleText(workspaceChip()),
+ chip: visibleText(workspaceChip()),
+ composerDisabled: composer.disabled,
+ sendDisabled: screen.getByRole('button', { name: 'Send message' }).disabled,
sidebar: visibleText(tree),
- composerDisabled: (composer as HTMLTextAreaElement).disabled,
- prompt: (composer as HTMLTextAreaElement).value,
}).toMatchInlineSnapshot(`
{
- "composerDisabled": false,
+ "chip": "New Workspace",
+ "composerDisabled": true,
"headline": "Let's start building",
- "prompt": "keep this local",
+ "sendDisabled": true,
"sidebar": "No sessions yet",
- "workspaceDraft": "workspace",
}
`)
})
-it('creates a real empty Workspace immediately and focuses its Session draft', async () => {
- boot('?fixture=empty')
+it('selects the recent Workspace and opens its blank Session on first load', async () => {
+ boot('?fixture')
- await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
- const workspaceSection = screen.getByText('Workspaces').parentElement
- if (workspaceSection === null) throw new Error('Workspace section missing')
- fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' }))
- fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
- fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
-
- const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
- fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
- target: { value: 'nova' },
- })
- fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
-
- const tree = await screen.findByRole('tree', { name: 'Sessions' })
- await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
- const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
- const draft = within(tree).getByText('New session').closest('[role="treeitem"]')
- if (group === null || draft === null) throw new Error('created Workspace projection missing')
+ const composer = await findHeroComposer()
+ const tree = screen.getByRole('tree', { name: 'Sessions' })
+ await waitFor(() => { expect(within(tree).getByText('4 sessions')).toBeDefined() }, { timeout: 10_000 })
expect({
- workspace: visibleText(group),
- draft: visibleText(draft),
- draftSelected: draft.getAttribute('aria-selected'),
- composerWorkspace: visibleText(workspaceChip()),
+ chip: visibleText(workspaceChip()),
+ composerDisabled: composer.disabled,
+ blankRow: within(tree).getByText('New Session').textContent,
}).toMatchInlineSnapshot(`
{
- "composerWorkspace": "nova",
- "draft": "New session",
- "draftSelected": "true",
+ "blankRow": "New Session",
+ "chip": "fixture",
+ "composerDisabled": false,
+ }
+ `)
+})
+
+it('creating a Workspace materializes and lists its selected blank Session', async () => {
+ boot('?fixture=empty')
+
+ await findLockedComposer()
+ await createWorkspaceViaPicker('nova')
+
+ // The pick connected the workspace: full Session+Agent exists, composer live.
+ const composer = await findHeroComposer()
+ const tree = screen.getByRole('tree', { name: 'Sessions' })
+ await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
+ expect(within(tree).getByText('New Session')).toBeDefined()
+ const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
+ if (group === null) throw new Error('created Workspace projection missing')
+
+ expect({
+ composerDisabled: composer.disabled,
+ chip: visibleText(workspaceChip()),
+ workspace: visibleText(group),
+ }).toMatchInlineSnapshot(`
+ {
+ "chip": "nova",
+ "composerDisabled": false,
"workspace": "nova1 session",
}
`)
})
-it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => {
- boot('?fixture')
+it('New Session reuses the Workspace blank session and converts the single visible row', async () => {
+ boot('?fixture=empty')
- const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ await findLockedComposer()
+ await createWorkspaceViaPicker('nova')
+ await findHeroComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
- await setComposerText(composer, 'discard this page-local draft')
- const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]')
- if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh')
+ await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
- const before = {
- workspace: visibleText(beforeGroup),
- draft: visibleText(within(tree).getByText('New session')),
- prompt: (composer as HTMLTextAreaElement).value,
- }
+ // New Session resolves through the recent Workspace and reuses its blank
+ // session in place: no locked interlude, no second entity.
+ fireEvent.click(screen.getByRole('button', { name: 'New session' }))
+ const composer = await findHeroComposer()
+ await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
- refresh('?fixture')
+ setComposerText(composer, 'first light')
+ fireEvent.keyDown(composer, { key: 'Enter' })
- const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
- const refreshedTree = screen.getByRole('tree', { name: 'Sessions' })
- const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]')
- if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh')
+ // Conversion: the accepted prompt flips blank without adding a second row.
+ await screen.findByText('first light', { exact: true }, { timeout: 10_000 })
+ await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
+ const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
+ if (group === null) throw new Error('converted Session projection missing')
expect({
- before,
- after: {
- workspace: visibleText(afterGroup),
- replacementDraft: visibleText(within(refreshedTree).getByText('New session')),
- prompt: (refreshedComposer as HTMLTextAreaElement).value,
- },
+ workspace: visibleText(group),
+ promptVisible: screen.getByText('first light', { exact: true }).textContent,
}).toMatchInlineSnapshot(`
{
- "after": {
- "prompt": "",
- "replacementDraft": "New session",
- "workspace": "fixture4 sessions",
- },
- "before": {
- "draft": "New session",
- "prompt": "discard this page-local draft",
- "workspace": "fixture4 sessions",
- },
+ "promptVisible": "first light",
+ "workspace": "nova1 session",
}
`)
})
-it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => {
+it('a failed Workspace attach recovers by reusing the published blank session', async () => {
boot('?fixture&fixtureAttach=fail')
- const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
- await setComposerText(composer, 'keep this cwd-only session')
- fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
+ // The rejected startup connect surfaces the locked view state first: the
+ // failure leaves no client-side transaction state to unwind.
+ await findLockedComposer()
+ // The host published the session before rejecting attachment (blank, with
+ // the workspace cwd), so the next connect — retry or manual pick — reuses
+ // it instead of minting a duplicate, and the hero opens on it.
+ await pickWorkspace('fixture')
+ const composer = await findHeroComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
- await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 })
- const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
- const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
- const ungroupedSection = ungroupedGroup?.parentElement
- if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) {
- throw new Error('Workspace or Ungrouped projection missing')
- }
- const session = within(ungroupedSection).getByRole('treeitem', { selected: true })
- const retained = screen.getByDisplayValue('keep this cwd-only session')
+ const group = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
+ if (group === null) throw new Error('fixture Workspace projection missing')
expect({
- workspace: visibleText(workspaceGroup),
- ungrouped: visibleText(ungroupedGroup),
- session: within(session).getByText('fixture', { exact: true }).textContent,
- sessionSelected: session.getAttribute('aria-selected'),
- prompt: (retained as HTMLTextAreaElement).value,
+ headline: visibleText(screen.getByText("Let's start building")),
+ composerDisabled: composer.disabled,
+ chip: visibleText(workspaceChip()),
+ workspace: visibleText(group),
}).toMatchInlineSnapshot(`
{
- "prompt": "keep this cwd-only session",
- "session": "fixture",
- "sessionSelected": "true",
- "ungrouped": "Ungrouped1 session",
+ "chip": "fixture",
+ "composerDisabled": false,
+ "headline": "Let's start building",
"workspace": "fixture3 sessions",
}
`)
})
-it('materializes the automatic Workspace and Session on the first successful send', async () => {
- boot('?fixture=empty')
-
- const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
- await setComposerText(composer, 'build a lighthouse')
- fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
-
- const tree = screen.getByRole('tree', { name: 'Sessions' })
- await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
- await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 })
- const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
- const session = within(tree).getByRole('treeitem', { selected: true })
- if (group === null) throw new Error('materialized Workspace projection missing')
-
- expect({
- workspace: visibleText(group),
- session: within(session).getByText('workspace', { exact: true }).textContent,
- sessionSelected: session.getAttribute('aria-selected'),
- promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent,
- }).toMatchInlineSnapshot(`
- {
- "promptVisible": "build a lighthouse",
- "session": "workspace",
- "sessionSelected": "true",
- "workspace": "workspace1 session",
- }
- `)
-})
-
-it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => {
+it('a rejected first prompt keeps the session blank and the draft in the machine', async () => {
boot('?fixture=empty&fixturePrompt=reject')
- const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
- await setComposerText(composer, 'do not lose this')
+ await findLockedComposer()
+ await createWorkspaceViaPicker('nova')
+ const composer = await findHeroComposer()
+
+ setComposerText(composer, 'do not lose this')
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
- const retained = screen.getByDisplayValue('do not lose this')
+ // Failure restore rides the machine (no pendingPrompt transaction): the
+ // draft returns to the same resident textarea one render later. The
+ // attempt flips the composer out of the hero (engaging = retry chrome),
+ // but acceptance never happened: the session row stays New Session.
+ const retained = await screen.findByDisplayValue('do not lose this')
const tree = screen.getByRole('tree', { name: 'Sessions' })
- await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
- const session = within(tree).getByRole('treeitem', { selected: true })
if (group === null) throw new Error('rejected-send Workspace projection missing')
expect({
- workspace: visibleText(group),
- session: within(session).getByText('workspace', { exact: true }).textContent,
error: visibleText(alert),
prompt: (retained as HTMLTextAreaElement).value,
+ blankRow: within(tree).getByText('New Session').textContent,
+ workspace: visibleText(group),
}).toMatchInlineSnapshot(`
{
- "error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance",
+ "blankRow": "New Session",
+ "error": "fixture: prompt rejected before acceptance (agent-busy)",
"prompt": "do not lose this",
- "session": "workspace",
- "workspace": "workspace1 session",
+ "workspace": "nova1 session",
+ }
+ `)
+})
+
+it('switching Workspace before the first message carries the draft to the new blank session', async () => {
+ boot('?fixture')
+
+ const composer = await findHeroComposer()
+ setComposerText(composer, 'carry me')
+
+ // Switch = session switch: the new workspace's blank session takes over,
+ // the typed draft moves machine-to-machine, the old blank stays hidden.
+ await createWorkspaceViaPicker('nova')
+ await waitFor(() => { expect(visibleText(workspaceChip())).toBe('nova') }, { timeout: 10_000 })
+ const carried = await screen.findByDisplayValue('carry me')
+ const tree = screen.getByRole('tree', { name: 'Sessions' })
+ const fixtureGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
+ const novaGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
+ if (fixtureGroup === null || novaGroup === null) throw new Error('Workspace projections missing after switch')
+
+ expect({
+ chip: visibleText(workspaceChip()),
+ prompt: (carried as HTMLTextAreaElement).value,
+ fixtureWorkspace: visibleText(fixtureGroup),
+ novaWorkspace: visibleText(novaGroup),
+ }).toMatchInlineSnapshot(`
+ {
+ "chip": "nova",
+ "fixtureWorkspace": "fixture3 sessions",
+ "novaWorkspace": "nova1 session",
+ "prompt": "carry me",
}
`)
})
diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts
new file mode 100644
index 0000000000..fd18d89087
--- /dev/null
+++ b/apps/web/tests/workspace-management.e2e.ts
@@ -0,0 +1,368 @@
+// Web e2e scenarios: workspace management — the create-by-name dialog, the
+// rename round trip over the real wire (workspace.rename RPC + durable
+// registry), duplicate-name pre-check, the flat "In one list" view with its
+// persisted group-by preference, and the session hover card. Zero model
+// calls: workspace.create/rename are host RPCs with no model involvement,
+// and the one session row the flat/hover scenarios need comes from a seeded
+// fixture (the seeded-history seed reused verbatim — no new recording).
+import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import {
+ acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole,
+ webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', import.meta.url))
+// The seed is another scenario's committed fixture, reused read-only: this
+// spec needs any one cold session row, not new recorded content.
+const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
+const MODE = webSnapshotMode()
+const SEED_ID = 'workspace-management-web-e2e'
+
+describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ let pickedDirectory: string | null = null
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({
+ rpcId: request.rpcId,
+ result: { ok: true, value: { path: pickedDirectory } },
+ })
+ // Seed one cold session (Ungrouped bucket) for the flat view + hover card.
+ const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
+ await mkdir(sessionCwd, { recursive: true })
+ await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
+ await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
+ await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('creates two workspaces by name through the region-header dialog', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create'))
+ const createByName = async (name: string): Promise => {
+ await page.getByRole('button', { name: 'Create workspace' }).click()
+ await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
+ await dialog.waitFor({ timeout: 10_000 })
+ await dialog.getByLabel('New workspace name').fill(name)
+ await dialog.getByRole('button', { name: 'Create workspace' }).click()
+ await expect.poll(() => page.getByRole('dialog', { name: 'Create a new workspace' }).count(), { timeout: 10_000 }).toBe(0)
+ // The real workspace materializes in the tree as a group row.
+ await expect.poll(() => page.getByText(name, { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
+ }
+ await createByName('alpha-ws')
+ await createByName('beta-ws')
+ // Durable on the host: both registered, newest first (create prepends).
+ const titles = scaffold.ctx.workspace.list().map(workspace => workspace.title)
+ expect(titles.slice(0, 2)).toEqual(['beta-ws', 'alpha-ws'])
+ expect(tripwire.pageErrors).toEqual([])
+ }, 90_000)
+
+ it('renames a workspace over the wire with a duplicate-name pre-check', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-rename'))
+ // The actions button is display:none until its row hovers — hover the
+ // group row first, then the revealed button becomes actionable.
+ await page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first().hover()
+ await page.getByRole('button', { name: 'Workspace actions for alpha-ws' }).click()
+ await page.getByRole('menuitem', { name: 'Rename' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Rename workspace' })
+ await dialog.waitFor({ timeout: 10_000 })
+ const input = dialog.getByLabel('Workspace name')
+ // Client pre-check: a name colliding with another live workspace raises
+ // the inline alert and blocks the primary button before any wire call.
+ await input.fill('beta-ws')
+ await expect.poll(() => dialog.getByRole('alert').count(), { timeout: 5_000 }).toBe(1)
+ expect(await dialog.getByRole('button', { name: 'Rename' }).isDisabled()).toBe(true)
+ // A fresh name goes through workspace.rename to the durable registry.
+ await input.fill('gamma-ws')
+ await expect.poll(() => dialog.getByRole('alert').count(), { timeout: 5_000 }).toBe(0)
+ await dialog.getByRole('button', { name: 'Rename' }).click()
+ await expect.poll(() => page.getByRole('dialog', { name: 'Rename workspace' }).count(), { timeout: 10_000 }).toBe(0)
+ await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
+ expect(await page.getByText('alpha-ws', { exact: true }).count()).toBe(0)
+ // Host durability, then reload: the projection is rebuilt from the wire.
+ expect(scaffold.ctx.workspace.list().map(workspace => workspace.title)).toContain('gamma-ws')
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 90_000)
+
+ it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete'))
+ const slotConsoleErrors: string[] = []
+ const transientSlotErrors: string[] = []
+ page.on('console', (message) => {
+ if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
+ slotConsoleErrors.push(message.text())
+ }
+ })
+ await page.exposeFunction('recordDshSlotError', (key: string) => {
+ if (!transientSlotErrors.includes(key)) transientSlotErrors.push(key)
+ })
+ await page.evaluate(() => {
+ const target = window as unknown as { recordDshSlotError(key: string): Promise }
+ const seen = new Set()
+ const collect = (): void => {
+ for (const node of document.querySelectorAll('[data-slot-error]')) {
+ const key = node.dataset.slotError ?? ''
+ if (!seen.has(key)) {
+ seen.add(key)
+ void target.recordDshSlotError(key)
+ }
+ }
+ }
+ new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
+ collect()
+ })
+ // Register the scaffold's existing project directory through the real UI.
+ pickedDirectory = scaffold.workspaceCwd
+ await page.getByRole('button', { name: 'Create workspace' }).click()
+ await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+
+ await expect.poll(
+ () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
+ { timeout: 10_000 },
+ ).not.toBeUndefined()
+ const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
+ if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
+ await workspace.attachSession(SessionId(SEED_ID))
+ const header = (await scaffold.ctx.sessionPersistence.list())
+ .find(candidate => candidate.id === SEED_ID)
+ if (header === undefined) throw new Error('seeded Session log disappeared before deletion')
+ const logLocation = scaffold.ctx.sessionPersistence.locate(header)
+ if (logLocation === undefined) throw new Error('JSONL persistence did not expose the seeded log path')
+ expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
+ await stat(logLocation.path)
+
+ // Open the seeded (first/accounted) Session so deletion must preserve the
+ // current selection while it moves into Ungrouped.
+ const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first()
+ await groupRow.waitFor({ timeout: 10_000 })
+ const groupSection = groupRow.locator('..')
+ if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click()
+ await expect.poll(
+ () => groupSection.locator('[role="treeitem"]').count(),
+ { timeout: 10_000 },
+ ).toBeGreaterThanOrEqual(2)
+ const seededRow = groupSection.locator('[role="treeitem"]').nth(1)
+ await seededRow.click()
+ await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
+
+ await groupRow.hover()
+ await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click()
+ await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Delete workspace' })
+ await dialog.waitFor({ timeout: 10_000 })
+ const copy = await dialog.textContent()
+ expect(copy).toContain('workspace list')
+ expect(copy).toContain('folder and session logs will be kept')
+ expect(copy).toContain('sessions will appear under Ungrouped')
+ await dialog.getByRole('button', { name: 'Delete workspace' }).click()
+ await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0)
+
+ expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined()
+ await expect.poll(
+ () => page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).count(),
+ { timeout: 10_000 },
+ ).toBe(0)
+ await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
+ .toBeGreaterThanOrEqual(1)
+ await expect.poll(
+ () => page.locator('[role="treeitem"][aria-selected="true"]').count(),
+ { timeout: 10_000 },
+ ).toBe(1)
+ expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
+ await stat(logLocation.path)
+ expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
+
+ // Re-registering the exact deleted path immediately, without a reload, is
+ // a supported reversible flow. It creates a fresh Workspace id without
+ // re-adopting the retained Session.
+ pickedDirectory = scaffold.workspaceCwd
+ await page.getByRole('button', { name: 'Create workspace' }).click()
+ await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+ await expect.poll(
+ () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
+ { timeout: 10_000 },
+ ).not.toBeUndefined()
+ const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
+ expect(reregistered?.id).toBeDefined()
+ expect(reregistered?.id).not.toBe(workspace.id)
+ expect(reregistered?.sessionIds).toEqual([])
+ await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
+ .toBeGreaterThanOrEqual(1)
+ expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
+ await stat(logLocation.path)
+
+ // Restore the deleted-registry state so reload still verifies deletion
+ // persistence independently of the successful re-registration above.
+ if (reregistered === undefined) throw new Error('same-path re-registration did not materialize')
+ await scaffold.ctx.workspace.delete(reregistered.id)
+ await expect.poll(
+ () => page.getByRole('button', { name: `Workspace actions for ${reregistered.title}` }).count(),
+ { timeout: 10_000 },
+ ).toBe(0)
+
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 })
+ .toBeGreaterThanOrEqual(1)
+ await expect.poll(
+ () => page.locator('[role="treeitem"][aria-selected="true"]').count(),
+ { timeout: 15_000 },
+ ).toBe(1)
+ expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined()
+ expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
+ await stat(logLocation.path)
+ expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
+
+ expect(transientSlotErrors).toEqual([])
+ expect(slotConsoleErrors).toEqual([])
+ expect(tripwire.pageErrors).toEqual([])
+ }, 90_000)
+
+ it('reuses a deleted title for a different new directory without any transient error surface', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-reuse-title'))
+ const title = 'same-name'
+ const oldPath = join(scaffold.workspaceCwd, 'adopted', title)
+ await mkdir(oldPath, { recursive: true })
+ const transientErrors: string[] = []
+ const consoleErrors: string[] = []
+ page.on('console', (message) => {
+ if (message.type() === 'error') consoleErrors.push(message.text())
+ })
+ await page.exposeFunction('recordDshTransientWorkspaceError', (message: string) => {
+ if (!transientErrors.includes(message)) transientErrors.push(message)
+ })
+ await page.evaluate(() => {
+ const target = window as unknown as {
+ recordDshTransientWorkspaceError(message: string): Promise
+ }
+ const collect = (): void => {
+ for (const node of document.querySelectorAll('[data-slot-error], [role="alert"]')) {
+ const message = node.dataset.slotError ?? node.textContent?.trim() ?? ''
+ if (message !== '') void target.recordDshTransientWorkspaceError(message)
+ }
+ }
+ new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
+ collect()
+ })
+
+ pickedDirectory = oldPath
+ await page.getByRole('button', { name: 'Create workspace' }).click()
+ await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+ await expect.poll(
+ () => scaffold.ctx.workspace.resolveByPath(oldPath),
+ { timeout: 10_000 },
+ ).not.toBeUndefined()
+ const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath)
+ if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered')
+
+ const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first()
+ await oldRow.hover()
+ await page.getByRole('button', { name: `Workspace actions for ${title}` }).click()
+ await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
+ await page.getByRole('dialog', { name: 'Delete workspace' })
+ .getByRole('button', { name: 'Delete workspace' }).click()
+ await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined()
+
+ await page.getByRole('button', { name: 'Create workspace' }).click()
+ await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
+ const create = page.getByRole('dialog', { name: 'Create a new workspace' })
+ await create.getByLabel('New workspace name').fill(title)
+ await create.getByRole('button', { name: 'Create workspace' }).click()
+ await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0)
+ const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title)
+ expect(fresh?.id).toBeDefined()
+ expect(fresh?.id).not.toBe(oldWorkspace.id)
+ expect(fresh?.path).toBe(join(scaffold.workspaceCwd, title))
+ expect(transientErrors).toEqual([])
+ expect(consoleErrors).toEqual([])
+ expect(tripwire.pageErrors).toEqual([])
+ }, 90_000)
+
+ it('switches to the flat "In one list" view and persists the preference', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat'))
+ // Grouped default: workspace group rows render (the seeded session sits
+ // under Ungrouped; the created workspaces are empty groups).
+ await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+ await page.getByRole('button', { name: 'Group by' }).click()
+ await page.getByRole('menuitem', { name: 'In one list' }).click()
+ // Flat mode: the section label flips and the seeded session is a
+ // top-level row with no group headers above it.
+ await expect.poll(() => page.getByText('Sessions', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
+ await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
+ await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
+ expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view'))).toContain('flat')
+ // Persisted across reload; then restore grouped for inter-spec hygiene.
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0)
+ await page.getByRole('button', { name: 'Group by' }).click()
+ await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
+ await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 90_000)
+
+ it('shows the session hover card after a dwell on the row', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
+ // Expand Ungrouped to reveal the seeded session row, then dwell on it
+ // (the card opens after a 500ms hover delay, portaled to body).
+ const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
+ const ungroupedSection = ungroupedRow.locator('..')
+ // Initial-current auto-expansion can race this following test's gesture;
+ // converge on expanded rather than assuming which update wins first.
+ await expect.poll(async () => {
+ if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
+ await page.getByText('Ungrouped', { exact: true }).click()
+ await page.waitForTimeout(50)
+ }
+ return await ungroupedRow.getAttribute('aria-expanded')
+ }, { timeout: 5_000 }).toBe('true')
+ // The only visible child is the non-blank persisted Session; the blank
+ // Session created while adopting the Workspace remains hidden.
+ const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
+ await sessionRow.waitFor({ timeout: 10_000 })
+ await sessionRow.hover()
+ // Card content: the full title plus the Idle status line (display-only
+ // card; no aria role — text anchors are the stable selector).
+ await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
+ // Leaving the anchor closes it with no delay.
+ await page.getByRole('button', { name: '设置' }).hover()
+ await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
+ expect(tripwire.warnings).toEqual([])
+ // This spec mints no fixture directory contents of its own; the seed it
+ // reuses is owned (and inventory-guarded) by seeded-history.
+ await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep'])
+ })
+})
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 54c5673451..8bed531598 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -23,8 +23,16 @@
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/scaffold.ts",
+ "tests/live-interactions.e2e.ts",
+ "tests/question-composer.e2e.ts",
+ "tests/steering.e2e.ts",
+ "tests/navigation-panes.e2e.ts",
+ "tests/lifecycle-chrome.e2e.ts",
+ "tests/settings-chrome.e2e.ts",
+ "tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
- "tests/seeded-history.e2e.ts"
+ "tests/seeded-history.e2e.ts",
+ "tests/code-mode-round.e2e.ts"
],
"references": [
{
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index da0c03b088..fb3b43e56c 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -12,7 +12,7 @@ Each fact has one home: the tier whose job it is. Elsewhere, link to that home;
| Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`, `.agents/notes/`) | Orders specific to that subtree | Repo-wide rules the root file already carries |
| [architecture.md](architecture.md) | The system map: services, the loop, extension seams — read before changing `packages/` | Type shapes (→ core-data-structures), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations |
| [core-data-structures/](core-data-structures/core.md) | The type catalog: literal shapes and semantics of the spine and seam vocabulary | Behavior narration (→ architecture.md) |
-| [Agent Notes](../.agents/notes/README.md) | Decision records: the why, what-was-given-up, and concise verification contract; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped |
+| [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and concise verification contract; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority |
| [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — |
| [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) |
| [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history |
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index e7b4f7f3ab..0a9b61346c 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write
-architecture.md: abdd1eda213a684f9745413b82aeefd4a6c296d3
-architecture.zh.md: 88088d3f76f161f207eb7ba53df943332c32c3c5
+# pnpm run verify-translation-pairing --write docs/architecture.md
+architecture.md: 78fee89a3e2bad9abc84c4171a69878153d6eb0e
+architecture.zh.md: 3393e015aae7728222e5c5245dd73fb9edf718da
diff --git a/docs/architecture.md b/docs/architecture.md
index abdd1eda21..78fee89a3e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -28,6 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls |
| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure |
| `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution |
+| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend |
| `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions |
| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) |
| `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home |
@@ -91,7 +92,7 @@ forever:
agent/pre-step
snapshot the derived messages (the reconstruction boundary)
'step/start'
- agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen)
+ agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound)
on final adapter-path or terminal in-band failure:
'step/end'
agent/request-error(original error, failure facts, immutable prior failures, signal)
@@ -125,7 +126,7 @@ Pruning precedes summaries; overflow retries require durable progress. Adapters
Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing.
-Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
+Other failures use `agent/error`. Cancellation and disposal beat recovery; the turn signal also cancels asynchronous model-capability preparation before any request header is committed, and undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap).
@@ -177,7 +178,7 @@ New behavior attaches to a documented extension point; a loop change updates thi
|---|---|
| Add a model provider | register an adapter on `ctx.llm` |
| Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly |
-| Add shell execution | implement and register a `ctx.bash` backend |
+| Add shell execution | implement and register a `ctx.bash` backend (the local one spawns through `ctx.subprocess`) |
| Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` |
| Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn |
| Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it |
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 88088d3f76..3393e015aa 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -28,6 +28,7 @@
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 |
| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 |
| `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 |
+| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 |
| `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 |
| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) |
| `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 |
@@ -91,7 +92,7 @@ forever:
agent/pre-step
snapshot the derived messages (the reconstruction boundary)
'step/start'
- agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen)
+ agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound)
on final adapter-path or terminal in-band failure:
'step/end'
agent/request-error(original error, failure facts, immutable prior failures, signal)
@@ -125,7 +126,7 @@ forever:
适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。
-其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
+其他故障使用 `agent/error`。取消和资源释放均优先于恢复;轮次信号还会在提交任何请求头之前取消异步模型能力准备,尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。
@@ -177,7 +178,7 @@ forever:
|---|---|
| 添加模型提供方 | 在 `ctx.llm` 上注册适配器 |
| 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 进入提示词组装流程 |
-| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端 |
+| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端(本地后端通过 `ctx.subprocess` 生成进程) |
| 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` |
| 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派该命令 |
| 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 |
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 0f2dfd1610..9093f41f15 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -35,6 +35,9 @@ flowchart LR
pkg_tool_bash["tool-bash"]
pkg_hooks_claude["hooks-claude"]
pkg_hooks_codex["hooks-codex"]
+ pkg_session_telemetry["session-telemetry"]
+ svc_telemetry["ctx.telemetry
Session telemetry seam"]
+ pkg_session_telemetry_otel["session-telemetry-otel"]
pkg_storage["storage"]
svc_storage["ctx.storage
Non-session storage hub"]
pkg_storage_json["storage-json"]
@@ -82,10 +85,15 @@ flowchart LR
pkg_agent_spine_demo["agent-spine-demo"]
pkg_goal["goal"]
svc_goals["ctx.goals
Same-session goal domain"]
- pkg_bash["bash"]
- svc_bash["ctx.bash
Bash executor seam"]
+ pkg_subprocess["subprocess"]
+ svc_subprocess["ctx.subprocess
Subprocess seam"]
+ pkg_subprocess_local["subprocess-local"]
pkg_bash_local["bash-local"]
pkg_bash_sandbox["bash-sandbox"]
+ pkg_lsp_local["lsp-local"]
+ pkg_subagent_acp["subagent-acp"]
+ pkg_bash["bash"]
+ svc_bash["ctx.bash
Bash executor seam"]
svc_bashEnv["ctx.bashEnv
Managed bash environment registry"]
pkg_pty["pty"]
svc_pty["ctx.pty
Persistent PTY session registry"]
@@ -113,10 +121,10 @@ flowchart LR
svc_subagents["ctx.subagents
Subagent provider registry"]
pkg_subagent_spawn["subagent-spawn"]
pkg_subagent_fork["subagent-fork"]
- pkg_subagent_acp["subagent-acp"]
pkg_tool_ralph["tool-ralph"]
pkg_tasks["tasks"]
svc_tasks["ctx.tasks
Background task registry"]
+ pkg_tasks_local["tasks-local"]
pkg_tool_tasks["tool-tasks"]
pkg_web["web"]
svc_web["ctx.web
Web access provider registry"]
@@ -175,6 +183,8 @@ flowchart LR
pkg_session_query --> svc_sessionQuery
pkg_session_query_sqlite --> svc_sessionQuery
pkg_session_reference --> svc_sessionReferences
+ pkg_session_telemetry --> svc_telemetry
+ pkg_session_telemetry_otel --> svc_telemetry
pkg_session_title --> svc_sessionTitle
pkg_session_title_all_messages_llm --> svc_sessionTitle
pkg_session_title_first_message_llm --> svc_sessionTitle
@@ -190,8 +200,11 @@ flowchart LR
pkg_subagent_acp --> svc_subagents
pkg_subagent_fork --> svc_subagents
pkg_subagent_spawn --> svc_subagents
+ pkg_subprocess --> svc_subprocess
+ pkg_subprocess_local --> svc_subprocess
pkg_system_prompt --> svc_systemPrompt
pkg_tasks --> svc_tasks
+ pkg_tasks_local --> svc_tasks
pkg_token_meter --> svc_tokenMeter
pkg_tool_bash --> svc_bashEnv
pkg_tools --> svc_tools
@@ -261,6 +274,10 @@ flowchart LR
svc_storageDomain --> pkg_workspace
svc_subagents --> pkg_tool_ralph
svc_subagents --> pkg_tool_subagent
+ svc_subprocess --> pkg_bash_local
+ svc_subprocess --> pkg_bash_sandbox
+ svc_subprocess --> pkg_lsp_local
+ svc_subprocess --> pkg_subagent_acp
svc_systemPrompt --> pkg_agent_loop
svc_systemPrompt --> pkg_tool_fs
svc_systemPrompt --> pkg_tool_pty
@@ -299,6 +316,7 @@ flowchart LR
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
+| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
@@ -315,6 +333,7 @@ flowchart LR
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. |
+| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
| `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. |
@@ -326,7 +345,7 @@ flowchart LR
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. |
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. |
-| `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. |
+| `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
| `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 2aa2530f87..c49225d117 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -59,7 +59,7 @@ export interface Config {
sessionTitle?: NonNullable
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
- /** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
+ /** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `true`. */
packChunks?: boolean
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
@@ -189,6 +189,8 @@ Source: [`packages/examples/agent-spine-demo/src/index.ts:88`](../packages/examp
## `@deepseek-ai/dsh-bash-local`
+Requires: `subprocess`
+
```ts config-catalog
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
@@ -207,11 +209,11 @@ export interface Config {
}
```
-Source: [`packages/bash/bash-local/src/index.ts:17`](../packages/bash/bash-local/src/index.ts)
+Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local/src/index.ts)
## `@deepseek-ai/dsh-bash-sandbox`
-Requires: `sandbox` · `sandboxPolicy`
+Requires: `subprocess` · `sandbox` · `sandboxPolicy`
```ts config-catalog
/**
@@ -560,18 +562,19 @@ Requires: `llm`
/**
* Plugin config, validated by the same-named schemastery schema. Every field
* is optional in yml: credentials/endpoint fall back to the environment (a
- * missing API key fails plugin load, not the first call), and omitted
- * thinking fields send nothing on the wire, so the provider default applies.
+ * missing API key fails plugin load, not the first call), omitted thinking
+ * mode uses the provider default, and omitted reasoning effort resolves to
+ * `high`.
*/
export interface Config {
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
apiKey?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
baseURL?: string
- /** Thinking-mode default for every request (provider default: enabled). */
+ /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
- /** Thinking effort (only meaningful with thinking enabled). */
- reasoningEffort?: 'high' | 'max'
+ /** Default thinking effort (default `high`); `off` disables thinking per request. */
+ reasoningEffort?: 'off' | 'high' | 'max'
/** Positive context capacity used when the selected model has no exact value. */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
@@ -582,7 +585,7 @@ export interface Config {
retryPolicy?: RetryPolicyConfig
}
-/** One optional model entry advertised by the hand-written adapter. */
+/** One optional model entry advertised by the direct-fetch adapter. */
export interface DeepSeekCatalogModel {
/** Wire model id accepted by the configured endpoint. */
id: string
@@ -597,7 +600,7 @@ export interface DeepSeekCatalogModel {
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
-Source: [`packages/llm/llm-deepseek/src/index.ts:35`](../packages/llm/llm-deepseek/src/index.ts)
+Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai`
@@ -621,7 +624,7 @@ export interface PiAiProviderProfile {
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record
/** Provider-neutral pi-ai reasoning level. */
- reasoning?: ThinkingLevel
+ reasoning?: ModelThinkingLevel
/** Token budgets used by reasoning providers that support them. */
thinkingBudgets?: ThinkingBudgets
/** Prompt-cache retention preference. */
@@ -639,7 +642,7 @@ export interface PiAiProviderProfile {
}
```
-Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
+Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
Source: [`packages/llm/llm-pi-ai/src/config.ts:54`](../packages/llm/llm-pi-ai/src/config.ts)
@@ -693,7 +696,7 @@ export interface ReplayModelConfig {
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
-Source: [`packages/support/llm-replay/src/index.ts:478`](../packages/support/llm-replay/src/index.ts)
+Source: [`packages/support/llm-replay/src/index.ts:617`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-llm-retry`
@@ -708,7 +711,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:45`](../packages/llm/llm-retry/src
## `@deepseek-ai/dsh-lsp-local`
-Requires: `lsp`
+Requires: `lsp` · `subprocess`
```ts config-catalog
/** Plugin configuration: provider id → local language-server configuration. */
@@ -744,7 +747,7 @@ export interface LspLocalServerConfig {
}
```
-Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts)
+Source: [`packages/lsp/lsp-local/src/index.ts:87`](../packages/lsp/lsp-local/src/index.ts)
## `@deepseek-ai/dsh-mcp-client`
@@ -841,7 +844,7 @@ export interface PlanModeConfig {
}
```
-Source: [`packages/plan/plan-mode/src/index.ts:58`](../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:57`](../packages/plan/plan-mode/src/index.ts)
## `@deepseek-ai/dsh-pty-local`
@@ -984,10 +987,9 @@ export interface Config {
/**
* Write runs of consecutive `assistant/chunk` delta events as packed
* `text-chunks`/`reasoning-chunks`/`tool-call-chunks` rows (lossless,
- * ~60% smaller logs measured on a real session). Off by default while
- * snapshot fixtures stay in the one-event-per-line layout: recording with
- * packing on rewrites every golden `session.jsonl`. READING packed rows is
- * unconditional — a log's layout never depends on this switch.
+ * ~60% smaller logs measured on a real session). Defaults to true; false
+ * keeps one `SessionEvent` per line for diagnostics. Reading packed rows is
+ * unconditional: a log's layout never depends on this switch.
*/
packChunks?: boolean
/** Physical encoding; defaults to checksummed Zstandard frames. */
@@ -998,7 +1000,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
-Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:39`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:40`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
## `@deepseek-ai/dsh-session-persistence-sqlite`
@@ -1090,6 +1092,40 @@ export interface Config {
Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts)
+## `@deepseek-ai/dsh-session-telemetry-otel`
+
+Requires: `sessions`
+
+```ts config-catalog
+/**
+ * Plugin configuration: two verbatim SDK option shapes plus nothing else.
+ * `exporter.url` is the one field this package validates itself — required,
+ * no default, must parse as an `http(s)` URL — because a missing endpoint
+ * must fail at plugin load, not at first export.
+ */
+export interface Config {
+ /**
+ * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
+ * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
+ * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
+ * is the one field this package requires and validates itself.
+ */
+ exporter?: OTLPExporterNodeConfigBase & {
+ /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */
+ url?: string
+ }
+ /**
+ * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
+ * which this plugin fills); the SDK owns and documents these knobs.
+ */
+ processor?: Omit
+}
+```
+
+Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
+
+Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts)
+
## `@deepseek-ai/dsh-session-title`
Requires: `sessions`
@@ -1196,7 +1232,7 @@ export interface Config {
}
```
-Source: [`packages/spill/spill-policy/src/index.ts:51`](../packages/spill/spill-policy/src/index.ts)
+Source: [`packages/spill/spill-policy/src/index.ts:60`](../packages/spill/spill-policy/src/index.ts)
## `@deepseek-ai/dsh-storage-domain`
@@ -1278,7 +1314,7 @@ Source: [`packages/storage/storage-sqlite/src/index.ts:24`](../packages/storage/
## `@deepseek-ai/dsh-subagent-acp`
-Requires: `subagents`
+Requires: `subagents` · `subprocess`
```ts config-catalog
/** Config: how to spawn and drive the child ACP agent process. */
@@ -1651,7 +1687,7 @@ Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tas
Requires: `tools` · `web` · `systemPrompt`
```ts config-catalog
-/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */
+/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */
export interface Config {
/** Register `web_search`. Defaults to true. */
search?: boolean
@@ -1663,10 +1699,12 @@ export interface Config {
fetchTimeoutMs?: number
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
searchTimeoutMs?: number
+ /** Cap on source characters converted and complete `web_fetch` output characters. Defaults to 200000. */
+ fetchMaxOutputChars?: number
}
```
-Source: [`packages/web/tool-web/src/index.ts:29`](../packages/web/tool-web/src/index.ts)
+Source: [`packages/web/tool-web/src/index.ts:35`](../packages/web/tool-web/src/index.ts)
## `@deepseek-ai/dsh-tool-workflow`
@@ -1698,13 +1736,21 @@ export interface Config {
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
*/
mode?: ToolPresentationMode
+ /**
+ * Concurrency cap for a `run_code` program's overlapping sub-calls
+ * (default 10, the loop scheduler's own default). Sub-calls follow the
+ * native scheduling contract — only calls whose tools classify
+ * concurrency-safe overlap; exclusive calls form barriers — so `1`
+ * restores strictly serial dispatch. Must be a positive integer.
+ */
+ maxParallelSubCalls?: number
}
/** How the registry presents its tools to the model (see {@link Config.mode}). */
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
-Source: [`packages/core/tools/src/index.ts:529`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:566`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-tui`
@@ -1768,7 +1814,7 @@ export interface TuiConfig {
}
```
-Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:273`](../packages/ui/tui/src/index.ts)
## `@deepseek-ai/dsh-tui-demo`
@@ -2031,13 +2077,20 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts))
-- `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts))
+- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts))
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts))
- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-slash` ([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
- `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts))
@@ -2052,7 +2105,8 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
-- `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
+- `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts))
+- `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts))
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
@@ -2071,6 +2125,8 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
+- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts))
+- `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
- `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts))
## Library packages (no plugin entry)
@@ -2095,8 +2151,8 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts))
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
- `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts))
+- `@deepseek-ai/dsh-session-telemetry` ([`packages/telemetry/session-telemetry/src/index.ts`](../packages/telemetry/session-telemetry/src/index.ts))
- `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts))
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
-- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts))
- `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts))
- `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts))
diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml
index 497ae08c32..c37bf85c1d 100644
--- a/docs/cookbook/adding-an-llm-adapter.i18n.yaml
+++ b/docs/cookbook/adding-an-llm-adapter.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write
-adding-an-llm-adapter.md: f20442b8c2ce823452a3ea13409f202185d12d04
-adding-an-llm-adapter.zh.md: 2864dd1e18742c7449e24f22504a5a38976ab450
+# pnpm run verify-translation-pairing --write docs/cookbook/adding-an-llm-adapter.md
+adding-an-llm-adapter.md: a7f9dced70041653a0cb815147a07b6386d79e3e
+adding-an-llm-adapter.zh.md: 3515927585201326b713bb03cd863886ce7846bd
diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md
index f20442b8c2..a7f9dced70 100644
--- a/docs/cookbook/adding-an-llm-adapter.md
+++ b/docs/cookbook/adding-an-llm-adapter.md
@@ -2,7 +2,7 @@
English | [中文](adding-an-llm-adapter.zh.md)
-How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against.
+How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (direct HTTP, SSE framed by `eventsource-parser`) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against.
## The shape
@@ -32,7 +32,7 @@ Registration is effect-based (HMR-safe); one adapter per provider route — dupl
- A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it.
- If the provider requires response ids, signatures, or other native metadata on follow-up calls, emit the minimal lossless-JSON projection as `finish.replayState`. Validate it when rebuilding history. `LlmService` passes it only when the historical provider route and target provider route are currently owned by the exact same adapter instance; your adapter decides whether same-model, cross-model, or cross-provider restoration is legal. Never infer native replay from provider/model names alone when state is absent.
-Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral.
+Provider-specific thinking-mode toggles remain in the adapter's Config. Exact model metadata uses one provider-neutral capability seam: implement `resolveModel()` with provider/model identity and optional `context` and `reasoning` fields, declare a configured `defaultEffort` only when one exists, and honor the resolver's optional `AbortSignal`. Reasoning efforts are ordered opaque ids mapped to provider requests by the adapter. Preserve the adapter's authoritative selectable list, including an adapter-defined `off` when supported, without exposing final wire spellings or clamping unsupported values; an id need not equal its wire representation.
## Structure that worked
diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md
index 2864dd1e18..3515927585 100644
--- a/docs/cookbook/adding-an-llm-adapter.zh.md
+++ b/docs/cookbook/adding-an-llm-adapter.zh.md
@@ -2,7 +2,7 @@
[English](adding-an-llm-adapter.md) | 中文
-如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(手写 HTTP/SSE)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。
+如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(直接 HTTP,SSE 由 `eventsource-parser` 分帧)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。
## 基本形态
@@ -32,7 +32,7 @@ export function apply(ctx: Context, config: Config) {
- 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。
- 如果提供方在后续调用中需要响应 ID、签名或其他原生元数据,请将其最小无损 JSON 投影作为 `finish.replayState` 发出。重建历史时验证该状态。只有历史提供方路由和目标提供方路由当前由完全相同的适配器实例拥有时,`LlmService` 才会传递该状态;由适配器决定同模型、跨模型或跨提供方恢复是否合法。状态缺失时,切勿仅根据提供方/模型名称推断原生回放。
-提供方特有的请求旋钮(thinking 模式、effort 级别)放在**适配器**的 Config 中,而非 `GenerateOptions` 中——核心词汇保持提供方无关。
+提供方特有的 thinking 模式开关仍放在适配器的 Config 中。确切模型元数据使用一处提供方无关的能力 seam:实现 `resolveModel()`,返回提供方/模型身份以及可选的 `context` 和 `reasoning` 字段;仅当存在配置指定的默认值时才声明 `defaultEffort`;响应传给解析器的可选 `AbortSignal`。推理强度是由适配器映射到提供方请求的有序不透明 ID。请保留适配器给出的权威可选列表,包括适配器在支持时定义的 `off`;不得暴露最终协议值的具体拼写,也不得自动调整不支持的值。ID 无需与其协议表示相同。
## 经验证有效的结构
diff --git a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml
new file mode 100644
index 0000000000..b983ffd591
--- /dev/null
+++ b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+maintaining-dsh-code-review.md: 2b5d0d926ae922f2650daac33cf35991cb71c5e5
+maintaining-dsh-code-review.zh.md: c0e8b64fde3a67174878b4b0665712c9ba2e67c0
diff --git a/docs/cookbook/maintaining-dsh-code-review.md b/docs/cookbook/maintaining-dsh-code-review.md
index 8af449b749..2b5d0d926a 100644
--- a/docs/cookbook/maintaining-dsh-code-review.md
+++ b/docs/cookbook/maintaining-dsh-code-review.md
@@ -1,5 +1,7 @@
# Maintaining the dsh-code-review skill
+English | [中文](maintaining-dsh-code-review.zh.md)
+
The [`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill is kept current by a single designated operator running a private periodic maintenance tool. This cookbook is the entry point for that operator — and for anyone taking over the role — and for repo contributors who want to understand why skill updates arrive as small periodic PRs rather than one-off audits. The workflow itself is specified in the [human-review skill-maintenance Agent Note](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md).
## What the maintainer receives
diff --git a/docs/cookbook/maintaining-dsh-code-review.zh.md b/docs/cookbook/maintaining-dsh-code-review.zh.md
new file mode 100644
index 0000000000..c0e8b64fde
--- /dev/null
+++ b/docs/cookbook/maintaining-dsh-code-review.zh.md
@@ -0,0 +1,64 @@
+# 维护 dsh-code-review skill
+
+[English](maintaining-dsh-code-review.md) | 中文
+
+[`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill(技能)由一名指定操作员通过私有的周期维护工具持续更新。本实操手册(cookbook)既是该操作员和接任者的入口,也帮助仓库贡献者理解为何 skill 更新会以小型周期 PR(Pull Request)的形式出现,而不是一次性审计。工作流本身由[人工评审 skill 维护 Agent Note(agent 决策记录)](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md)规定。
+
+## 维护者会收到什么
+
+每天运行私有工具,并使用 2 个 UTC 日的重叠窗口;在拟议的调度器完成验收运行之前,操作员按相同频率手动调用包装脚本。每周手动恢复运行使用 7 日窗口。工作流会:
+
+1. 选择指定窗口内合并、且合并 commit 可从 `origin/master` 到达的 PR(每天运行默认选择 2 个 UTC 日,每周运行选择 7 日)。合并 commit 无法到达的 PR(例如父分支被 squash 的堆叠分支),或超出 250 个 commit 获取上限的 PR,会记录到 `skipped-pulls.json` 并跳过,不会中止本次运行。
+2. 收集合并前带 commit 锚点的人工评审反馈(行内评论和评审提交),然后比较反馈时与最终落地的 PR patch。它不获取 PR 会话评论,因为 GitHub 当前状态无法为这些评论提供可抵抗 force-push 的反馈时基线;它也不会把只存在于目标分支的变更作为采纳证据。
+3. 两个独立配置的评审适配器先对来源和采纳情况分类,再根据当前 skill 对双方一致认定已采纳的条目分类。
+4. 主适配器起草完整修订版 `SKILL.md`;两个适配器评审同一份 diff;只要仍有阻塞发现,循环就会继续,直到双方批准。
+5. 工具声明成功前,会针对候选版本运行 `pnpm run doc-sync` 和 `pnpm run lint`。
+
+每次运行都把产物保存在操作员的机器上。保存的 diff、候选 `SKILL.md` 和提升 manifest(元数据清单)按时间戳命名,存放在 `~/dsh-code-review-outputs/` 下。manifest 记录源 master commit 与 skill blob、源反馈 ID 和 URL、已落地证据范围、适配器裁决和门禁结果;每个适配器的原始 I/O 留在私有临时目录中,该目录路径会写入通知和 `~/Library/Logs/dsh-code-review-maintainer/` 下的每日日志。维护 worktree 在每次运行后都会恢复为干净状态,避免操作员直接在维护副本中编辑。
+
+## 操作员如何处理候选 diff
+
+某次运行产出候选版本时,macOS 会发出一条带 `dsh-code-review-promote ` 提示的通知。
+
+1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受:维护者契约规定最终判断由操作员作出。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。
+
+ ```sh
+ ls ~/dsh-code-review-outputs/ # every candidate ever produced
+ less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.diff
+ less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.SKILL.md
+ less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.manifest.json
+ ```
+
+2. **与运行产物交叉核验。** 提升 manifest 会把每条拟议规则映射到源反馈和已落地证据;每个适配器的详细 I/O、共识和采纳证据位于本次运行的私有临时目录中(路径见日志)。至少抽查一个候选项:链接的人工评论是否确实支持新增规则?链接的 PR 是否确实采纳了它?
+
+3. **从三种处理方式中选择一种:**
+ - **丢弃。** 删除保存的候选版本。下一次运行会依据届时的当前 skill,重新考虑同一份反馈。
+
+ ```sh
+ rm ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.{diff,SKILL.md,manifest.json}
+ ```
+ - **暂存成批。** 如果更新很小,可以把候选版本留待与后续版本合并。源 skill 检查仍然适用;如果 `master` 先发生变化,请重新运行分析,或手动 rebase 并重新评审 diff。
+ - **提升。** 在仓库的干净 `master` checkout 中运行提升辅助工具。它会刷新 `master`、验证当前 skill 与记录的源 blob 一致、应用保存的 diff,并创建一份 draft PR,其正文包含 manifest 的来源摘要。如果 skill 已发生漂移,它会停止而不是覆盖更新后的指导;操作员仍需在 GitHub 上评审 PR,并选择合并或关闭。
+
+ ```sh
+ cd ~/path/to/deepseek-harness # clean master
+ dsh-code-review-promote 2026-07-16T02-00-00Z
+ ```
+
+4. **不要逐字提交适配器输出。** 提升过程中可以进行小幅编辑,例如收紧措辞、移除只有结合源 PR 上下文才有意义的示例、把规则并入现有规则。这些编辑是预期行为,也保留了工作流所依赖的「评审者判断」。合并前应在该分支上修订这些改动。
+
+## 运行未产出候选版本时
+
+只要每个非空分类阶段都至少产生一个有效的适配器结果,这就是常见情况。工具会在每日日志中记录「无候选版本」,不发送通知(避免提醒疲劳),然后继续。某天没有 skill 更新,说明工作流运行正常,而不是停滞。
+
+## 中断与交接
+
+该机制运行在一台机器上。操作员应随时处理以下中断:
+
+- **错过每日运行。** 2 日重叠窗口会自动覆盖一次漏跑;更长的间隔可通过设置 `DSH_CODE_REVIEW_SINCE=` 手动运行包装脚本来恢复。重叠窗口具有幂等性:当前 skill 已包含的指导会被归类为 `covered`,不会再次成为候选项。
+- **适配器提供方中断。** 当两个评审命令解析为逐字节相同的可执行文件时,工具会拒绝运行。某个批次的适配器响应未通过 schema 或 ID 校验时,该批次会整体 fail-closed(其中每个条目都标记为不明确),运行则继续;原始输出会保留以便调试。如果任一适配器在某项操作的所有非空批次中都未产生有效结果,本次运行就会失败、写入失败记录并通知操作员;它绝不会把提供方完全中断折叠成「无候选版本」。
+- **交接给另一名维护者。** 新建一篇取代当前记录的后续 Agent Note:要么把机制移入仓库,要么记录新操作员的私有设置。不要暗中转交工具;Agent Note 的风险章节已把「单维护者关键人风险」列为交接必须记录决策的原因。
+
+## 操作员的私有设置位于何处
+
+工具源代码、评审适配器、提供方凭据和调度器属于操作员的私有基础设施,按设计位于本仓库之外(参见 Agent Note 的「机制位于何处」章节)。本实操手册和 Agent Note 描述的是**工作流保证什么**;这些保证**如何**实现则属于私有基础设施问题。如果你是新操作员,应以 Agent Note 的 `## Proposal` 各节作为实现依据。
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 2fcb2822e1..78bbc1af16 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -9,7 +9,7 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie
The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).
-Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).
+Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`), **bail** (synchronous in-order dispatch until one listener returns a bail value; the scoped input-mutation events use it for an applied/not-applied answer).
## `agent/*`
@@ -618,7 +618,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
-Source: [`packages/llm/llm/src/index.ts:55`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:56`](../../packages/llm/llm/src/index.ts)
## `session/*`
@@ -710,6 +710,75 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru
Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts)
+## `slash/*`
+
+### `slash/input-begin-command` — bail
+
+Applies one command claim to the scoped Input. Dispatched with the session's scope carrier; the owning session's input listener returns `true` only after the phase and span CAS checks pass and the machine actually mutated — producers treat anything else as "not applied".
+
+```ts cordis-catalog
+/**
+ * Applies one command claim to the scoped Input. Dispatched with the
+ * session's scope carrier; the owning session's input listener returns
+ * `true` only after the phase and span CAS checks pass and the machine
+ * actually mutated — producers treat anything else as "not applied".
+ * @param request - Claim and menu-time span CAS.
+ * @mode bail
+ */
+'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
+```
+
+Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts)
+
+### `slash/input-consume-token` — bail
+
+Consumes one command token after business success (popup settle / menu-pick execute). Same carrier routing and applied-truth contract.
+
+```ts cordis-catalog
+/**
+ * Consumes one command token after business success (popup settle /
+ * menu-pick execute). Same carrier routing and applied-truth contract.
+ * @param request - Exact span or bare-token guard.
+ * @mode bail
+ */
+'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
+```
+
+Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts)
+
+### `slash/input-insert-reference` — bail
+
+Inserts one reference into the scoped Input (same carrier routing and applied-truth contract as begin-command).
+
+```ts cordis-catalog
+/**
+ * Inserts one reference into the scoped Input (same carrier routing and
+ * applied-truth contract as begin-command).
+ * @param request - Reference and menu-time span CAS.
+ * @mode bail
+ */
+'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
+```
+
+Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts)
+
+### `slash/input-insert-text` — bail
+
+Replaces the trigger token span with literal text — the plain-text reference path (decision 21). Same carrier routing and applied-truth contract; the draft gains ordinary characters, no occurrence entry.
+
+```ts cordis-catalog
+/**
+ * Replaces the trigger token span with literal text — the plain-text
+ * reference path (decision 21). Same carrier routing and applied-truth
+ * contract; the draft gains ordinary characters, no occurrence entry.
+ * @param request - Replacement text and menu-time span CAS.
+ * @mode bail
+ */
+'slash/input-insert-text'(request: InsertTextRequest): true | undefined
+```
+
+Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts)
+
## `subagent/*`
### `subagent/end` — emit
@@ -825,6 +894,35 @@ Emitted when any prompt provider changes. This registry notification is unfilter
Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts)
+## `telemetry/*`
+
+### `telemetry/record` — waterfall
+
+Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten.
+
+```ts cordis-catalog
+/**
+ * Transform one outbound record before it reaches the backend. This
+ * waterfall is the seam's redaction extension point. It ships NO rules
+ * of its own: the
+ * innermost `next()` passes the record through unchanged, and with no
+ * listener mounted records reach the backend as captured, so exported
+ * data is exactly as clean as the rules a deployment mounts. Listeners
+ * stack by transforming `next()`'s return value; returning without
+ * `next()` replaces everything beneath. Dispatched synchronously on the
+ * capture hot path inside the coordinator's containment: a throwing
+ * listener withholds that one record (fail-closed) and never reaches the
+ * agent loop. Redaction applies to the exported copy only; the canonical
+ * session log is never rewritten.
+ * @param record - the candidate record, already the coordinator's own deep
+ * copy; listeners return a (possibly new) record and must not mutate it.
+ * @mode waterfall
+ */
+'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
+```
+
+Source: [`packages/telemetry/session-telemetry/src/index.ts:41`](../../packages/telemetry/session-telemetry/src/index.ts)
+
## `tools/*`
### `tools/change` — emit
@@ -844,7 +942,31 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
'tools/change'(): void
```
-Source: [`packages/core/tools/src/index.ts:143`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:156`](../../packages/core/tools/src/index.ts)
+
+### `tools/code-dispatch-log` — waterfall
+
+Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches.
+
+```ts cordis-catalog
+/**
+ * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before
+ * the bridge appends its `tool/code-dispatch` event. `next()` keeps the
+ * content unchanged; a listener may return replacement blocks (e.g. the
+ * spill policy's preview + locator for an oversized text result). Only the
+ * logged copy is affected — the program already received the complete
+ * value, and the model sees neither. A throwing listener is contained:
+ * the bridge falls back to logging the unshaped content.
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches.
+ * @param dispatch - the parent execution, sub-call identity, and the settled content to log.
+ * @mode waterfall
+ */
+'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise
+```
+
+Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md)
+
+Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts)
### `tools/execute` — waterfall
@@ -929,7 +1051,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
-Source: [`packages/core/tools/src/index.ts:133`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:146`](../../packages/core/tools/src/index.ts)
## `workflow/*`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 0491430662..005816686c 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -257,7 +257,7 @@ Implementations must honor these semantics:
- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a BashRunResult.
- start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr.
- BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
-- Disposal kills all running background processes and awaits their exit.
+- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.
```ts cordis-catalog
/**
@@ -286,7 +286,7 @@ abstract start(spec: BashExecSpec): BashProcess
Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashProcess](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md)
-Source: [`packages/bash/bash/src/index.ts:48`](../../packages/bash/bash/src/index.ts)
+Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/index.ts)
## `ctx.bashEnv` — `BashEnvRegistry`
@@ -315,7 +315,7 @@ collect(execution: ToolExecution): DshEnvironment
list(): BashEnvVariableInfo[]
```
-Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../core-data-structures/tools.md)
+Types: [DshEnvironment](../core-data-structures/subprocess.md) · [ToolExecution](../core-data-structures/tools.md)
Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts)
@@ -737,33 +737,57 @@ providerRetryPolicy(provider: string): ResolvedRetryPolicy
async listModels(provider: string): Promise
/**
- * Resolve context capacity from the adapter that owns one exact route.
- * This query is independent of the advisory model catalog: an unlisted model
- * may return metadata, while `undefined` never rejects later routing.
+ * Resolve and validate all metadata from the adapter that owns one exact
+ * route. The result is detached from adapter-owned objects; catalog
+ * membership remains advisory and does not control request routing.
* @param provider - registered provider route to inspect.
* @param model - exact model id passed to the adapter.
- * @returns detached context metadata, or `undefined` when the adapter has none.
+ * @param signal - optional cancellation for adapter-owned asynchronous lookup.
+ * @returns exact model identity plus available context and reasoning metadata.
*/
-async resolveModelContext( provider: string, model: string, ): Promise
+async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise
+
+/**
+ * Validate a conversation call config against its exact model capability and
+ * materialize an adapter-configured default. Unsupported explicit efforts
+ * reject before provider I/O; no clamping or aliasing is performed. This
+ * standalone query does not bind a later dispatch; use {@link prepareCall}
+ * when logging and streaming must share one adapter registration.
+ * @param config - provider/model route and optional request controls.
+ * @param signal - optional cancellation for adapter-owned capability lookup.
+ * @returns a detached config only when a default must be materialized.
+ */
+async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise
+
+/**
+ * Resolve one call under its current adapter registration. The returned
+ * one-shot handle keeps that registration across header logging and dispatch,
+ * so HMR cannot combine one adapter's capability result with another adapter.
+ * @param config - provider/model route and optional request controls.
+ * @param signal - optional cancellation for adapter-owned capability lookup.
+ * @returns a prepared config and its registration-bound stream entry point.
+ */
+async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise
/**
* Stream one model call as raw chunks (token-level deltas). Throws
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
* `options.provider`. Replay state is retained only when the same adapter
* instance owns its historical provider and the target provider. Final
- * adapter selection, dispatch, and iteration failures retain their original
- * Error identity and are tagged in a call-local scope for narrow agent-loop
- * request recovery; middleware and nested-call failures remain untagged for
- * the outer call.
+ * adapter selection remains fixed through asynchronous exact-model resolution
+ * and dispatch. Selection, dispatch, and iteration failures retain their
+ * original Error identity and are tagged in a call-local scope for narrow
+ * agent-loop request recovery; middleware and nested-call failures remain
+ * untagged for the outer call.
* @param options - the full request; `options.provider` selects the adapter.
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
*/
stream(options: GenerateOptions): AsyncIterable
```
-Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
+Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
-Source: [`packages/llm/llm/src/index.ts:171`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:189`](../../packages/llm/llm/src/index.ts)
## `ctx.permission` — `PermissionService`
@@ -834,7 +858,7 @@ set(agent: Agent, active: boolean): void
Types: [Agent](../core-data-structures/core.md)
-Source: [`packages/plan/plan-mode/src/index.ts:142`](../../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts)
## `ctx.pty` — `PtyService`
@@ -1349,7 +1373,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:625`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
@@ -1566,6 +1590,31 @@ Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](
Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/subagent/src/index.ts)
+## `ctx.subprocess` — `SubprocessService` (abstract seam)
+
+Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
+
+Implementations must honor these semantics:
+
+- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures.
+- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.
+- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.
+- Disposal of the service terminates all still-running managed processes and awaits their exit.
+
+```ts cordis-catalog
+/**
+ * Start one managed child process from a fully-specified spec; this seam
+ * applies no defaults.
+ * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
+ * @returns the live process handle (streams/readers, signalling, outcome promise).
+ */
+abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
+```
+
+Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md)
+
+Source: [`packages/subprocess/subprocess/src/index.ts:88`](../../packages/subprocess/subprocess/src/index.ts)
+
## `ctx.systemPrompt` — `SystemPrompt`
Registry service for the prompt inputs assembled before each model step.
@@ -1614,9 +1663,16 @@ Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSec
Source: [`packages/core/system-prompt/src/index.ts:246`](../../packages/core/system-prompt/src/index.ts)
-## `ctx.tasks` — `TaskService`
+## `ctx.tasks` — `TaskService` (abstract seam)
-The `tasks` service: the runtime-global background task registry. See the module doc for the ownership, isolation, and lifecycle contracts.
+Abstract background task registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.tasks` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
+
+Implementations must honor these semantics:
+
+- Registrations outlive producer and control-surface fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record.
+- Owned-task access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
+- Settlement is first-wins: one terminal record, one round of contained listener notification, and released waiters, even against a late producer outcome.
+- start refuses work while no control surface is attached, so a producer cannot start work that callers cannot collect or stop.
```ts cordis-catalog
/**
@@ -1627,7 +1683,7 @@ The `tasks` service: the runtime-global background task registry. See the module
* @param spec - task identity, owner, and synchronous starter.
* @returns the registry-issued `-N` id.
*/
-start(spec: TaskStart): TaskId
+abstract start(spec: TaskStart): TaskId
/**
* List caller-owned and unowned tasks in registration order without exposing
@@ -1635,7 +1691,7 @@ start(spec: TaskStart): TaskId
* @param caller - reading agent; a non-agent caller sees only unowned tasks.
* @returns fresh snapshots.
*/
-list(caller?: Agent): TaskSnapshot[]
+abstract list(caller?: Agent): TaskSnapshot[]
/**
* Return a non-consuming snapshot without changing its read cursor or notice
@@ -1644,7 +1700,7 @@ list(caller?: Agent): TaskSnapshot[]
* @param caller - reading agent checked against the owner.
* @returns a fresh snapshot.
*/
-get(id: TaskId, caller?: Agent): TaskSnapshot
+abstract get(id: TaskId, caller?: Agent): TaskSnapshot
/**
* Read the next stream delta, or the idempotent final output after settlement.
@@ -1654,7 +1710,7 @@ get(id: TaskId, caller?: Agent): TaskSnapshot
* @param caller - reading agent checked against the owner.
* @returns output text and the post-read snapshot.
*/
-read(id: TaskId, caller?: Agent): TaskRead
+abstract read(id: TaskId, caller?: Agent): TaskRead
/**
* Request cancellation, then mark the task stopping and reported. A producer
@@ -1665,21 +1721,20 @@ read(id: TaskId, caller?: Agent): TaskRead
* @param reason - logged reason forwarded to the producer.
* @returns `requested` for live work, otherwise `already-finished`.
*/
-kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
+abstract kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
/**
* Wait for settlement or timeout without cancelling the task. Caller abort
- * rejects only while the task is live; after settlement it returns the
- * terminal snapshot so a notice suppressed for this waiter is still delivered.
- * Timed-out and aborted waits detach their resolvers. Throws for invalid,
- * unknown, or foreign input.
+ * rejects only while the task is live; after settlement the terminal
+ * snapshot wins so a notice suppressed for this waiter is still delivered.
+ * Throws for invalid, unknown, or foreign input.
* @param id - task to wait for.
* @param timeoutMs - positive finite wait bound in milliseconds.
* @param caller - waiting agent checked against the owner.
* @param signal - optional cancellation of the wait itself.
* @returns snapshot at settlement or timeout.
*/
-async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise
+abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise
/**
* Register an effect-scoped completion listener. Each listener is contained;
@@ -1688,7 +1743,7 @@ async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal):
* @param listener - receives each terminal snapshot and its exact owner.
* @returns disposer that unregisters the listener.
*/
-onTaskDone(listener: TaskDoneListener): () => void
+abstract onTaskDone(listener: TaskDoneListener): () => void
/**
* Attach an effect-scoped surface that can read and stop tasks. {@link start}
@@ -1696,12 +1751,35 @@ onTaskDone(listener: TaskDoneListener): () => void
* @param name - diagnostic label; duplicate names remain independent.
* @returns disposer that detaches this surface.
*/
-attachSurface(name: string): () => void
+abstract attachSurface(name: string): () => void
```
Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-data-structures/tasks.md) · [TaskId](../core-data-structures/tasks.md) · [TaskRead](../core-data-structures/tasks.md) · [TaskSnapshot](../core-data-structures/tasks.md) · [TaskStart](../core-data-structures/tasks.md)
-Source: [`packages/tasks/tasks/src/index.ts:77`](../../packages/tasks/tasks/src/index.ts)
+Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts)
+
+## `ctx.telemetry` — `Telemetry` (abstract seam)
+
+The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side.
+
+```ts cordis-catalog
+/**
+ * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home.
+ * @param record - the logical record to report; owned by the backend after the call.
+ */
+abstract emit(record: TelemetryRecord): void
+
+/** See {@link TelemetryBackend.flush}. */
+flush?(): void
+
+/**
+ * See {@link TelemetryBackend.shutdown}.
+ * @returns resolves when the backend's pipeline has quiesced.
+ */
+abstract shutdown(): Promise
+```
+
+Source: [`packages/telemetry/session-telemetry/src/index.ts:135`](../../packages/telemetry/session-telemetry/src/index.ts)
## `ctx.tokenMeter` — `TokenMeterService`
@@ -1856,7 +1934,7 @@ async execute(exec: ToolExecutionInput): Promise
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
-Source: [`packages/core/tools/src/index.ts:634`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:688`](../../packages/core/tools/src/index.ts)
## `ctx.tui` — `TuiExtensionService` (abstract seam)
@@ -1879,7 +1957,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
-Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:153`](../../packages/ui/tui/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
@@ -2017,13 +2095,14 @@ get(id: WorkspaceId): Workspace | undefined
list(): Workspace[]
/**
- * Move one accounted, cwd-validated session to the front of its workspace.
- * Ungrouped sessions and candidates filtered by the header check are
- * no-ops. The owning workspace's relative position never changes.
- * @param sessionId - Session whose activity was observed.
- * @returns resolution after the possible record write.
+ * Delete one workspace registration while retaining its directory and every
+ * session log. The durable order is updated before the table deletion; a
+ * failed table write restores the prior order and keeps the entity
+ * published. Unknown ids are an idempotent no-op for domain callers.
+ * @param id - Workspace registration to remove.
+ * @returns `true` when a record was deleted, `false` when it was unknown.
*/
-async touchSession(sessionId: SessionId): Promise
+delete(id: WorkspaceId): Promise
/**
* Resolve by canonical directory path without creating or mutating a
@@ -2035,9 +2114,7 @@ async touchSession(sessionId: SessionId): Promise
async resolveByPath(path: string): Promise
```
-Types: [SessionId](../core-data-structures/core.md)
-
-Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts)
+Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts)
## Inherited `ctx` members (cordis core + loader/hmr/timer)
diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml
new file mode 100644
index 0000000000..6ab341e8f5
--- /dev/null
+++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+01-first-plugin.md: b730b7ad7dc9ebd2e5dc8af4f7a83bd58ac7d4d8
+01-first-plugin.zh.md: 1e6901c048f5268ddead0faca85eaf5eef9c7533
diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md
index 084f9964e1..b730b7ad7d 100644
--- a/docs/cordis-tutorial/01-first-plugin.md
+++ b/docs/cordis-tutorial/01-first-plugin.md
@@ -1,5 +1,7 @@
# 1. Your first plugin
+English | [中文](01-first-plugin.zh.md)
+
In the loader configuration used here, a Cordis plugin module named-exports an `apply` function. When Cordis loads it, it calls `apply` with a **context** — the `ctx` object through which the plugin registers everything it contributes.
## Write the plugin
diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md
new file mode 100644
index 0000000000..1e6901c048
--- /dev/null
+++ b/docs/cordis-tutorial/01-first-plugin.zh.md
@@ -0,0 +1,95 @@
+# 1. 编写第一个插件
+
+[English](01-first-plugin.md) | 中文
+
+在本教程使用的 loader 配置中,Cordis 插件模块通过命名导出提供 `apply` 函数。Cordis 加载模块时,会用一个 **上下文** 调用 `apply`;该上下文就是 `ctx` 对象,插件通过它注册自己贡献的所有内容。
+
+## 编写插件
+
+在 `tmp/cordis-tutorial` 目录中(参见[环境设置](index.md#setup))创建 `hello.ts`:
+
+```ts
+import type { Context } from 'cordis'
+
+export const name = 'hello'
+
+export function apply(ctx: Context) {
+ console.log('hello from my first plugin')
+}
+```
+
+`name` 导出项是可选的显示元数据;它用于在诊断信息中标识插件。
+
+## 组合应用
+
+本教程的启动器通过配置组装应用。创建 `cordis.yml`:
+
+```yaml
+- name: './hello.ts'
+```
+
+该文件是一组 Cordis 配置项的列表。`name` 是模块指定符,可以是相对路径或 NPM 包(package)名;loader 会挂载每个配置项。各项会并发启动,因此它们在列表中的位置不保证插件的加载先后;顺序由服务依赖(`inject`,参见[第 3 章](03-services.md))决定,而非文件中的位置。
+
+## 运行
+
+```sh
+node --import tsx ../../vendor/cordis/bin.js
+```
+
+预期输出:
+
+```
+hello from my first plugin
+```
+
+当没有任何内容继续运行时,进程会自行退出。具体过程如下:
+
+1. 启动器创建根 `Context`,并挂载 **Loader** 插件。
+2. Loader 读取 `cordis.yml`,解析 `./hello.ts`,然后将其作为子插件挂载。
+3. Cordis 调用你的 `apply(ctx)`。
+
+你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[TUI agent(智能体)](../../examples/tui-agent/cordis.yml) 就是一个更长的插件组合。
+
+## 其他两种插件形态
+
+函数是最常见的形态,但 Cordis 接受三种形态:
+
+```ts
+import { Service, type Context } from 'cordis'
+
+// 1. Function plugin (what you just wrote).
+export function apply(ctx: Context) {}
+
+// 2. Object plugin: an object with an `apply` method.
+export const objectPlugin = {
+ name: 'object-plugin',
+ apply(ctx: Context) {},
+}
+
+// 3. Class plugin: a Service subclass (covered in chapter 3).
+export class MyService extends Service {
+ constructor(ctx: Context) {
+ super(ctx, 'myTutorialService')
+ }
+}
+```
+
+在你需要公开服务之前,请一直使用函数形态;[第 3 章](03-services.md)介绍了何时应当使用类形态。
+
+## 尝试制造错误
+
+让 `apply` 抛出异常:
+
+```ts ignore-check
+export function apply(ctx: Context) {
+ throw new Error('apply exploded')
+}
+```
+
+再次运行:进程会因该错误而终止。插件加载失败必须明确报错,不会仅跳过该配置项。
+
+还需要尽早了解一个例外:如果某个配置项的模块无法被 **解析**,例如路径或包名拼写错误,Cordis 会通过 logger 服务报告错误,而不会使进程崩溃。在启动阶段,这条报告可能在 console 导出器开始观察之前丢失。如果新增配置项似乎没有任何效果,请先检查拼写。
+
+下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml
new file mode 100644
index 0000000000..6d9e4bb6fd
--- /dev/null
+++ b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+02-lifecycle-and-effects.md: f1b39e06e9d25c51ab2d76503025e2b6ffe90c73
+02-lifecycle-and-effects.zh.md: a6021ed7475a0045d480810747244274eb5b4198
diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md
index 68a0f12ec8..f1b39e06e9 100644
--- a/docs/cordis-tutorial/02-lifecycle-and-effects.md
+++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md
@@ -1,5 +1,7 @@
# 2. Lifecycle and effects
+English | [中文](02-lifecycle-and-effects.zh.md)
+
A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in `ctx.effect()`.
## Effects
diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md
new file mode 100644
index 0000000000..a6021ed747
--- /dev/null
+++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md
@@ -0,0 +1,98 @@
+# 2. 生命周期与 effect
+
+[English](02-lifecycle-and-effects.md) | 中文
+
+Cordis 插件可能因配置编辑、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 `ctx.effect()` 中。
+
+## Effect
+
+对于 Cordis 尚未管理的资源,例如定时器、连接或 watcher,应将其包装在 `ctx.effect()` 中并返回 disposer(dispose(资源释放)函数):
+
+创建 `lifecycle.ts`,将它放在 `tmp/cordis-tutorial` 中:
+
+```ts
+import type { Context } from 'cordis'
+
+export const name = 'lifecycle-demo'
+
+function heartbeat(ctx: Context) {
+ console.log('heartbeat plugin loading')
+ ctx.effect(() => {
+ const timer = setInterval(() => console.log('tick'), 200)
+ return () => {
+ clearInterval(timer)
+ console.log('heartbeat cleaned up')
+ }
+ })
+}
+
+export function apply(ctx: Context) {
+ // Mount a child plugin and keep its fiber to dispose it later.
+ const fiber = ctx.plugin(heartbeat)
+ // The demo timer is itself an effect: if THIS plugin is unloaded first,
+ // the pending callback is cancelled instead of firing on a dead app.
+ ctx.effect(() => {
+ const timer = setTimeout(async () => {
+ await fiber.dispose()
+ console.log('disposed')
+ process.exit(0)
+ }, 700)
+ return () => clearTimeout(timer)
+ })
+}
+```
+
+让 `cordis.yml` 指向该文件:
+
+```yaml
+- name: './lifecycle.ts'
+```
+
+运行(`node --import tsx ../../vendor/cordis/bin.js`)后会得到:
+
+```
+heartbeat plugin loading
+tick
+tick
+tick
+heartbeat cleaned up
+disposed
+```
+
+请留意三点:
+
+- `ctx.plugin(heartbeat)` 会把一个**来自代码**的函数挂载为插件,这与 YAML loader 为每个配置项执行的操作相同。函数插件不需要 `apply` 方法:Cordis 会直接调用该函数,其名称只用于诊断。只有对象形态才要求 `apply` 方法,例如 `ctx.plugin({ apply(ctx) { /* ... */ } })`。调用会返回一个 **fiber**,即一个已加载插件实例的运行时句柄。
+- effect 主体在加载期间运行;它返回的 disposer 在卸载期间运行。对于生命周期与插件一致的资源,你绝不需要自行调用 disposer。
+- `fiber.dispose()` 会等该插件的所有清理工作(包括异步 disposer)完成后才结束,并递归卸载它挂载的所有子插件。
+
+## Fiber 状态机
+
+每个已加载插件实例都拥有一个 fiber,并依次经过以下状态:
+
+```
+PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
+ ↘ FAILED
+```
+
+- **PENDING**:已经声明,但所需服务(第 3 章)尚不可用。
+- **LOADING / ACTIVE**:`apply` 正在运行/已经完成。
+- **FAILED**:`apply` 或配置校验抛出异常。
+- **UNLOADING / DISPOSED**:disposer 正在运行/一切均已拆除。
+
+你会在[第 6 章](06-composition-and-hmr.md)再次遇到 PENDING,它通常就是「为什么我的插件没有输出」的答案。
+
+## 已经属于 effect 的操作
+
+你很少需要亲自编写 `ctx.effect()`,因为内置注册 API 本身已经是 effect:
+
+- `ctx.on(event, listener)`:监听器会在卸载时移除([第 4 章](04-events.md))。
+- `ctx.plugin(child)`:子插件会随父插件一同 dispose。
+- 服务注册属于 effect。`ctx.tools.register(...)` 等 harness 注册表也会把返回的 disposer 附着到调用插件上,因此会自动回卷([第 7 章](07-into-the-harness.md))。
+
+对于 Cordis 不管理的资源,应在 `ctx.effect()` 内获取它,并返回用于释放资源的 disposer。此后 Cordis 会在卸载期间调用该释放逻辑,热重载时也不例外。
+
+有一项顺序注意事项:disposer 会按注册顺序的逆序启动,但多个**异步** disposer 会并发运行。如果拆除步骤必须按顺序执行,请把它们放在同一个 disposer 中,并在其中依次等待每步完成。
+
+下一章:[服务](03-services.md):插件如何共享功能。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml
new file mode 100644
index 0000000000..d42d5eb250
--- /dev/null
+++ b/docs/cordis-tutorial/03-services.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+03-services.md: 5848132c6ad18338fa893954d45fc20005db6199
+03-services.zh.md: 3c77d0451df9062f1a344e7474e6be141b709197
diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md
index 9f62003e99..5848132c6a 100644
--- a/docs/cordis-tutorial/03-services.md
+++ b/docs/cordis-tutorial/03-services.md
@@ -1,5 +1,7 @@
# 3. Services
+English | [中文](03-services.zh.md)
+
A **service** is a named capability one plugin provides and other plugins consume through `ctx`. In the harness, `ctx.tools`, `ctx.llm`, and `ctx.agents` are services. A consumer names the capability, such as `'tools'`, rather than importing its provider, so configuration can select a provider without changing the consumer.
## Provide a service
diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md
new file mode 100644
index 0000000000..3c77d0451d
--- /dev/null
+++ b/docs/cordis-tutorial/03-services.zh.md
@@ -0,0 +1,98 @@
+# 3. 服务
+
+[English](03-services.md) | 中文
+
+**服务**是一个插件提供、其他插件通过 `ctx` 消费的命名功能。在 harness 中,`ctx.tools`、`ctx.llm` 和 `ctx.agents` 都是服务。消费方只命名 `'tools'` 之类的功能,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。
+
+## 提供服务
+
+创建 `greeter.ts`,将它放在 `tmp/cordis-tutorial` 中:
+
+```ts
+import { Service, type Context } from 'cordis'
+
+declare module 'cordis' {
+ interface Context {
+ greeter: GreeterService
+ }
+}
+
+export class GreeterService extends Service {
+ constructor(ctx: Context) {
+ super(ctx, 'greeter')
+ }
+
+ greet(who: string) {
+ return `Hello, ${who}!`
+ }
+}
+
+export const name = 'greeter'
+
+export function apply(ctx: Context) {
+ ctx.plugin(GreeterService)
+}
+```
+
+两部分协同工作:
+
+- **运行时**:`super(ctx, 'greeter')` 以名称 `greeter` 注册该实例。此后,任何插件都可以通过 `ctx.greeter` 访问它。注册属于 effect,卸载提供方时会移除该服务。
+- **编译时**:`declare module 'cordis'` 块使用 TypeScript 声明合并,把 `greeter` 加入 `Context` 接口,使 `ctx.greeter` 在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。
+
+`Service` 子类本身就是插件(第 1 章介绍的类形态),因此 `ctx.plugin(GreeterService)` 会像挂载其他插件一样挂载它。
+
+## 使用 `inject` 消费服务
+
+创建 `consumer.ts`:
+
+```ts
+import type { Context } from 'cordis'
+
+export const name = 'consumer'
+export const inject = ['greeter']
+
+export function apply(ctx: Context) {
+ console.log(ctx.greeter.greet('world'))
+}
+```
+
+`inject` 列出该插件需要的服务。Cordis 会让插件保持 PENDING,直到列出的每项服务都存在,因此在 `apply` 内可以保证 `ctx.greeter` 已经就绪。`cordis.yml` 中的加载顺序无关紧要:决定插件何时启动的是依赖关系,而不是文件顺序。
+
+组合并运行:
+
+```yaml
+- name: './greeter.ts'
+- name: './consumer.ts'
+```
+
+```
+Hello, world!
+```
+
+交换 `cordis.yml` 中两行的顺序后重新运行,输出仍然相同。尝试彻底移除 `./greeter.ts`:消费方会保持 PENDING,不输出任何内容,既不崩溃,也不会只运行一部分。处于 PENDING 的 fiber 也不会让 Node 的事件循环保持活跃,因此如果组合中没有其他运行项,进程会静默地以状态码 0 退出。[第 6 章](06-composition-and-hmr.md)介绍如何诊断这种状态。
+
+## 加载后仍会跟踪依赖关系
+
+`inject` 并非一次性的启动检查。如果应用运行期间所需服务消失,例如提供方被卸载或热替换,每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect([第 2 章](02-lifecycle-and-effects.md)),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会回卷。
+
+这也是配置中可以替换服务的原因:卸载 `dsh-bash-local` 配置项,挂载另一个 `bash` 提供方,所有注入 `'bash'` 的插件都会干净地重启并使用新实现。
+
+## 可选依赖
+
+`inject` 用于硬性依赖。如果某项功能缺失时插件仍可运行,请跳过 `inject`,并在使用处探测:
+
+```ts ignore-check
+export function apply(ctx: Context) {
+ // undefined when no provider is loaded; the plugin still runs.
+ const greeter = ctx.get('greeter')
+ console.log(greeter?.greet('maybe') ?? 'no greeter available')
+}
+```
+
+## 命名
+
+每个应用中的服务名称共用一个扁平命名空间。请为自有服务添加有辨识度的前缀或命名空间(harness 已占用 `tools` 和 `llm` 等普通名称);生成的[服务目录](../cordis-catalog/services.md)列出 harness 注册的每个名称。
+
+下一章:[事件](04-events.md):无需共享服务即可通信。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml
new file mode 100644
index 0000000000..cd1fc962a7
--- /dev/null
+++ b/docs/cordis-tutorial/04-events.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+04-events.md: 18f39dc1b693e5fb7e1793ec4b7dcac9cf24db95
+04-events.zh.md: f55a61ff2f43ea42968893d07eb92ea0613b921a
diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md
index 4c14ba5313..18f39dc1b6 100644
--- a/docs/cordis-tutorial/04-events.md
+++ b/docs/cordis-tutorial/04-events.md
@@ -1,5 +1,7 @@
# 4. Events
+English | [中文](04-events.zh.md)
+
Services support direct calls; **events** let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions.
## Declare, emit, listen
diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md
new file mode 100644
index 0000000000..f55a61ff2f
--- /dev/null
+++ b/docs/cordis-tutorial/04-events.zh.md
@@ -0,0 +1,144 @@
+# 4. 事件
+
+[English](04-events.md) | 中文
+
+服务支持直接调用;**事件**让插件无需知道有哪些插件正在监听,就能发出通知。harness 使用事件处理工具结果、模型请求和审批决定等交互。
+
+## 声明、发出与监听
+
+创建 `stats.ts`,将它放在 `tmp/cordis-tutorial` 中。它是一项负责计数并在每次变化时发出通知的服务:
+
+```ts
+import { Service, type Context } from 'cordis'
+
+declare module 'cordis' {
+ interface Context {
+ stats: StatsService
+ }
+ interface Events {
+ 'stats/report'(name: string, count: number): void
+ }
+}
+
+export class StatsService extends Service {
+ private counts = new Map()
+
+ constructor(ctx: Context) {
+ super(ctx, 'stats')
+ }
+
+ bump(name: string) {
+ const next = (this.counts.get(name) ?? 0) + 1
+ this.counts.set(name, next)
+ this.ctx.emit('stats/report', name, next)
+ }
+}
+
+export const name = 'stats'
+
+export function apply(ctx: Context) {
+ ctx.plugin(StatsService)
+}
+```
+
+`interface Events` 合并与第 3 章的 `interface Context` 合并在事件系统中相互对应:它声明事件名称及其监听器签名,因此 `ctx.emit` 和 `ctx.on` 都具有完整类型。`namespace/action` 命名约定让扁平的事件命名空间保持易读。
+
+创建 `reporter.ts`:
+
+```ts ignore-check
+import type { Context } from 'cordis'
+import type {} from './stats.ts'
+
+export const name = 'reporter'
+export const inject = ['stats']
+
+export function apply(ctx: Context) {
+ ctx.on('stats/report', (name, count) => {
+ console.log(`[stats] ${name} -> ${count}`)
+ })
+ ctx.stats.bump('tool_call')
+ ctx.stats.bump('tool_call')
+ ctx.stats.bump('prompt')
+}
+```
+
+`import type {} from './stats.ts'` 行不会在运行时导入任何内容;它的作用是让 TypeScript 看到声明合并。组合并运行:
+
+```yaml
+- name: './stats.ts'
+- name: './reporter.ts'
+```
+
+```
+[stats] tool_call -> 1
+[stats] tool_call -> 2
+[stats] prompt -> 1
+```
+
+因为 `ctx.on()` 属于 effect,监听器会随插件一同消失,绝不需要手动维护 `removeListener`。
+
+## 分发模式
+
+`emit` 是 5 种分发模式之一。事件采用哪种模式是其契约的一部分,决定了监听器能否返回值、能否并发运行,以及能否彼此短路:
+
+| 模式 | 调用 | 语义 |
+|---|---|---|
+| emit | `ctx.emit(name, ...args)` | 同步广播;不会等待或收集返回的 promise 与值。 |
+| parallel | `await ctx.parallel(name, ...args)` | 所有监听器并发运行,并一同等待。 |
+| serial | `await ctx.serial(name, ...args)` | 监听器按顺序运行并等待;第一个非 `null`/`false`/`undefined` 返回值胜出,并停止后续监听器。 |
+| bail | `ctx.bail(name, ...args)` | serial 的同步版本。 |
+| waterfall(瀑布式事件) | `ctx.waterfall(name, ...args, next)` | 环绕中间件,见下文。 |
+
+每个 harness 事件都会在生成的[事件目录](../cordis-catalog/events.md)中记录其模式。
+
+## Waterfall:转换或短路
+
+waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 `next()` continuation;它可以转换 `next()` 的返回值,也可以不调用 `next()` 就直接返回,从而短路链条的其余部分。Cordis 文档把后一种行为称为否决。创建 `waterfall-demo.ts`:
+
+```ts
+import type { Context } from 'cordis'
+
+declare module 'cordis' {
+ interface Events {
+ 'demo/transform'(input: string, next: () => Promise): Promise
+ }
+}
+
+export const name = 'waterfall-demo'
+
+export function apply(ctx: Context) {
+ // Listener 1: wrap the downstream result.
+ ctx.on('demo/transform', async (input, next) => {
+ const downstream = await next()
+ return downstream.toUpperCase()
+ })
+
+ // Listener 2: short-circuit when it owns the decision.
+ ctx.on('demo/transform', async (input, next) => {
+ if (input.includes('blocked')) return '** blocked **'
+ return next()
+ })
+
+ void (async () => {
+ console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
+ console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
+ })()
+}
+```
+
+让 `cordis.yml` 只指向该文件并运行:
+
+```
+HELLO
+** BLOCKED **
+```
+
+按顺序看第二行如何产生:监听器 1 先运行并调用 `next()`,从而调用监听器 2;监听器 2 看到 `blocked` 后直接返回而不调用 `next()`,因此最内层默认逻辑(传给 `ctx.waterfall` 的函数)从未运行;返回途中,监听器 1 再把替换消息转换为大写。
+
+由此得到一项纪律:**只负责观察或标注的 waterfall 监听器必须调用 `next()`**;不调用就直接返回代表有意短路。如果日志监听器忘记调用 `next()`,会悄无声息地吞掉所有下游的默认行为。这一点极其重要,已成为本仓库的常设规则([waterfall 语义](../cordis-primer.md#cordis-waterfall-semantics))。
+
+harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`agent/request`](../cordis-catalog/events.md#agentrequest--waterfall) 允许插件替换模型调用配置,[`approval/request`](../cordis-catalog/events.md#approvalrequest--waterfall) 允许策略代替用户作答。
+
+下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml
new file mode 100644
index 0000000000..deb6f119c2
--- /dev/null
+++ b/docs/cordis-tutorial/05-config.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+05-config.md: fc19add239636fa9e7071d9c77e48595caec1f08
+05-config.zh.md: 52a75e40672c9a08d285677dd14dcd404b925e5a
diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md
index 09aaf8971d..fc19add239 100644
--- a/docs/cordis-tutorial/05-config.md
+++ b/docs/cordis-tutorial/05-config.md
@@ -1,5 +1,7 @@
# 5. Configuration
+English | [中文](05-config.zh.md)
+
Each `cordis.yml` entry can carry a `config` block, and the plugin declares a schema that validates it before `apply` runs. Bad config fails the load with a precise error — the plugin never starts half-configured.
## A configurable plugin
diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md
new file mode 100644
index 0000000000..52a75e4067
--- /dev/null
+++ b/docs/cordis-tutorial/05-config.zh.md
@@ -0,0 +1,84 @@
+# 5. 配置
+
+[English](05-config.md) | 中文
+
+每个 `cordis.yml` 配置项都可以携带 `config` 块,插件则声明一个 schema,在运行 `apply` 前验证该块。错误配置会导致加载失败,并给出准确的错误:插件绝不会在配置不完整时启动。
+
+## 可配置插件
+
+创建 `config-demo.ts`,并将其放在 `tmp/cordis-tutorial` 中:
+
+```ts
+import type { Context } from 'cordis'
+import Schema from 'schemastery'
+
+export const name = 'config-demo'
+
+export interface Config {
+ greeting: string
+ targets: string[]
+}
+
+export const Config: Schema = Schema.object({
+ greeting: Schema.string().default('Hello'),
+ targets: Schema.array(String).default(['world']),
+})
+
+export function apply(ctx: Context, config: Config) {
+ for (const target of config.targets) {
+ console.log(`${config.greeting}, ${target}!`)
+ }
+}
+```
+
+导出的 `Config` 既是 TypeScript 接口,也是同名的运行时 schema:消费方获得类型,Cordis 获得验证器。本仓库使用 [Schemastery](https://github.com/shigma/schemastery) 定义 schema;Cordis 本身接受任意 [Standard Schema](https://standardschema.dev/) 验证器,因此将普通对象导出为 `Config` 无法工作。
+
+对其进行配置:
+
+```yaml
+- name: './config-demo.ts'
+ config:
+ targets: ['alpha', 'beta']
+```
+
+运行:
+
+```
+Hello, alpha!
+Hello, beta!
+```
+
+未提供 `greeting`,因此 schema 默认值会将其补齐:`apply` 始终会收到完整且经过验证的配置。
+
+## 明确报错
+
+现在向它传入无效内容:
+
+```yaml
+- name: './config-demo.ts'
+ config:
+ targets: 'not-an-array'
+```
+
+```
+ValidationError: invalid config:
+ - $.targets expected array but got not-an-array (at targets)
+```
+
+插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的 schema 有效配置命名了不可用的资源或提供方,该插件也应当在能解析该引用时立即拒绝。
+
+## 计算得到的配置值
+
+本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值,例如从环境中读取 API key:
+
+```yaml
+- name: '@deepseek-ai/dsh-llm-deepseek'
+ config:
+ apiKey: !!js process.env.DEEPSEEK_API_KEY
+```
+
+`!!js` **仅在 `config` 内有效**。配置项元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。
+
+下一章:[组合与 HMR](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml
new file mode 100644
index 0000000000..01f9345de3
--- /dev/null
+++ b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+06-composition-and-hmr.md: 66d6a9d93fe39baa881940ba32388979e2678505
+06-composition-and-hmr.zh.md: ebe63fc26607ae6d9344c4795a7975496ed901b5
diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md
index bb236cc169..66d6a9d93f 100644
--- a/docs/cordis-tutorial/06-composition-and-hmr.md
+++ b/docs/cordis-tutorial/06-composition-and-hmr.md
@@ -1,5 +1,7 @@
# 6. Composition and HMR
+English | [中文](06-composition-and-hmr.zh.md)
+
Every capability built so far is a plugin, and `cordis.yml` selects the application's plugin tree. This chapter changes that composition, hot-reloads a plugin, and diagnoses a plugin that never loads.
## Entries are more than a name
diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md
new file mode 100644
index 0000000000..ebe63fc266
--- /dev/null
+++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md
@@ -0,0 +1,113 @@
+# 6. 组合与 HMR(热模块替换)
+
+[English](06-composition-and-hmr.md) | 中文
+
+到目前为止构建的每项功能都是插件,`cordis.yml` 则选择应用的插件树。本章会改变这种组合、热重载一个插件,并诊断始终无法加载的插件。
+
+## 配置项不只有名称
+
+配置项除了 `name` 和 `config`,还接受其他元数据:
+
+```yaml
+- id: greeter # stable identity for this entry
+ name: './greeter.ts'
+- id: consumer
+ name: './consumer.ts'
+ disabled: true # keep the entry, skip mounting it
+```
+
+`id` 为配置项提供稳定标识,使 loader 能区分修改现有配置项与先删除再添加。`disabled: true` 会卸载插件而不删除其配置项;改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。
+
+组可以嵌套一份配置项子列表,并将其作为一个单元加载和卸载;`isolate` 则为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的 `bash`,互不影响。这些概念值得在用到之前先了解;[Cordis 入门](../cordis-primer.md)和[服务隔离示例](../user/develop/framework/service.md#service-isolation)介绍了详细内容。
+
+## 热模块替换
+
+卸载会释放 effect([第 2 章](02-lifecycle-and-effects.md)),加载则遵循依赖关系([第 3 章](03-services.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@cordisjs/plugin-hmr` 插件会监视文件,并在保存时执行这一过程。
+
+在 `tmp/cordis-tutorial` 中编写 `cordis.yml`:
+
+```yaml
+- id: logger
+ name: '@cordisjs/plugin-logger-console'
+- id: timer
+ name: '@cordisjs/plugin-timer'
+- id: hmr
+ name: '@cordisjs/plugin-hmr'
+ config:
+ root: ['.']
+- id: hello
+ name: './hello.ts'
+```
+
+列表中增加了两个支持插件:HMR 通过 Cordis logger 服务记录日志,因此没有 console exporter 时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@cordisjs/plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。
+
+HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构。请在 tsx 下运行 Cordis:
+
+```sh
+node --import tsx ../../vendor/cordis/bin.js
+```
+
+现在编辑 `hello.ts`,修改日志消息并保存:
+
+```
+hello from my first plugin
+2026-07-22 15:44:36 [I] hmr watching [ '.' ]
+2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts
+hello from my EDITED plugin
+```
+
+旧实例先卸载(其所有 effect 都会回卷),新代码随后加载,`apply` 再次运行。按 Ctrl-C 停止进程。编辑 `cordis.yml` 本身也会触发更新:loader 按 `id` 比较配置项,只挂载、卸载或重新配置发生变化的部分。这就是上述配置项显式携带 `id` 的原因:不带该字段的配置项在每次读取时都会获得一个新生成的 id,所以只要配置文件发生任何编辑,即使自身文本未变,它也会被视为先删除再添加并重新挂载。
+
+## 诊断始终无法加载的插件
+
+依赖驱动加载也有另一面:如果插件的 `inject` 指定了无人提供的服务,它就会一直等待,不输出任何内容。这不是错误,因为 PENDING 是合法状态,提供方可能稍后才挂载。
+
+你可以直接查看这些状态。每个上下文都能枚举插件注册表;创建 `diagnose.ts`:
+
+```ts
+import { FiberState, type Context } from 'cordis'
+
+export const name = 'diagnose'
+
+export function apply(ctx: Context) {
+ setTimeout(() => {
+ for (const runtime of ctx.registry.values()) {
+ for (const fiber of runtime.fibers) {
+ if (fiber.state === FiberState.PENDING) {
+ console.log(`${fiber.name} is PENDING — a required service is missing`)
+ }
+ }
+ }
+ }, 500)
+}
+```
+
+再创建一个依赖无法满足的插件 `needs-timer.ts`:
+
+```ts
+import type { Context } from 'cordis'
+
+export const name = 'needs-timer'
+export const inject = ['timer']
+
+export function apply(ctx: Context) {
+ console.log('needs-timer loaded')
+}
+```
+
+```yaml
+- name: './needs-timer.ts'
+- name: './diagnose.ts'
+```
+
+运行它(直接执行 `node --import tsx ../../vendor/cordis/bin.js`,按 Ctrl-C 停止):
+
+```
+needs-timer is PENDING — a required service is missing
+```
+
+`inject: ['timer']` 没有提供方。向列表添加 `- name: '@cordisjs/plugin-timer'` 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。
+
+下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml
new file mode 100644
index 0000000000..c85bcad755
--- /dev/null
+++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+07-into-the-harness.md: 6ec42c50fe5059955734fe7bc46117538dafaffc
+07-into-the-harness.zh.md: 32b21b008837e2972a53db9d893788dc6a7de9a9
diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md
index a86c538d48..6ec42c50fe 100644
--- a/docs/cordis-tutorial/07-into-the-harness.md
+++ b/docs/cordis-tutorial/07-into-the-harness.md
@@ -1,5 +1,7 @@
# 7. Into the harness
+English | [中文](07-into-the-harness.zh.md)
+
This chapter registers a model-callable tool with the harness's `tools` service, executes it through the harness tool pipeline, and observes the result event. It remains keyless and does not call a model.
## A tool plugin
diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md
new file mode 100644
index 0000000000..32b21b0088
--- /dev/null
+++ b/docs/cordis-tutorial/07-into-the-harness.zh.md
@@ -0,0 +1,107 @@
+# 7. 进入 harness
+
+[English](07-into-the-harness.md) | 中文
+
+本章会向 harness 的 `tools` 服务注册一个可由模型调用的工具,通过 harness 工具流水线执行它,并观察结果事件。整个示例无需密钥,也不会调用模型。
+
+## 工具插件
+
+创建 `greet-tool.ts`,将它放在 `tmp/cordis-tutorial` 中:
+
+```ts
+import type { Context } from 'cordis'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import { CallId } from '@deepseek-ai/dsh-llm'
+
+export const name = 'greet-tool'
+export const inject = ['tools']
+
+export function apply(ctx: Context) {
+ ctx.tools.register(defineTool({
+ name: 'greet',
+ description: 'Greet the named person.',
+ parameters: {
+ name: { type: 'string', required: true, description: 'Who to greet' },
+ },
+ output: {
+ schema: { type: 'string' },
+ render: (_args, value) => [{ type: 'text', text: value }],
+ },
+ async execute(args) {
+ return `Hello, ${args.name}!`
+ },
+ }))
+
+ // Drive one call through the real execution pipeline, standing in for
+ // the model. CallId brands the correlation id a provider would issue.
+ void (async () => {
+ const result = await ctx.tools.execute({
+ callId: CallId('demo-1'),
+ name: 'greet',
+ arguments: { name: 'Cordis' },
+ signal: new AbortController().signal,
+ })
+ console.log('tool replied:', JSON.stringify(result.content))
+ })()
+}
+```
+
+这里的每个模式都来自前几章:`inject: ['tools']`([第 3 章](03-services.md))会让插件等待工具注册表就绪;`ctx.tools.register(...)` 会把注册 disposer 附着到插件([第 2 章](02-lifecycle-and-effects.md)),因此卸载时会注销工具。`defineTool` 将 `parameters` 规约转换为向模型展示的 JSON Schema,推导 `args` 的类型,并在 `execute` 运行前校验模型提供的参数。工具返回由 `output.schema` 声明的规范值;`output.render` 则另行生成原生且持久的结果内容。
+
+## 观察插件
+
+创建 `tool-logger.ts`。这是一个独立插件,通过 harness 的 `tools/result` 事件观察应用中的每次工具调用:
+
+```ts
+import type { Context } from 'cordis'
+import type {} from '@deepseek-ai/dsh-tools'
+
+export const name = 'tool-logger'
+export const inject = ['tools']
+
+export function apply(ctx: Context) {
+ ctx.on('tools/result', (exec, result) => {
+ const text = result.content
+ .map(block => (block.type === 'text' ? block.text : ''))
+ .join('')
+ console.log(`[tool-logger] ${exec.name} -> ${text}`)
+ })
+}
+```
+
+`import type {} from '@deepseek-ai/dsh-tools'` 行会引入该包的声明合并,使 `'tools/result'` 及其 payload 具有类型。这与第 4 章导入 `stats.ts` 的做法相同,只是扩展到了包级别。
+
+## 组合并运行
+
+```yaml
+- name: '@deepseek-ai/dsh-system-prompt'
+- name: '@deepseek-ai/dsh-tools'
+- name: './tool-logger.ts'
+- name: './greet-tool.ts'
+```
+
+`@deepseek-ai/dsh-tools` 会注入 `systemPrompt` 服务,因为工具需要向系统提示词贡献 schema,所以组合中也要列出该服务的提供方。缺少提供方时,工具插件会像[第 6 章](06-composition-and-hmr.md)所述那样保持 PENDING。
+
+```sh
+node --import tsx ../../vendor/cordis/bin.js
+```
+
+```
+[tool-logger] greet -> Hello, Cordis!
+tool replied: [{"type":"text","text":"Hello, Cordis!"}]
+```
+
+logger 会先触发:`tools/result` 在结果物化过程中发出,早于 `execute` 的 promise 向调用方返回结果。两个插件都不知道另一个插件存在,它们由注册表服务和事件连接。
+
+## 从这里走向完整 agent(智能体)
+
+真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和前端。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。
+
+后续可以阅读:
+
+- [构建工具](../user/develop/basic/tool.md):深入了解 `defineTool`,包括呈现和更丰富的 schema。
+- [三层功能设计](../user/develop/practice/index.md):harness 如何组织可替换功能。
+- 生成的[服务](../cordis-catalog/services.md)与[事件](../cordis-catalog/events.md)目录:可以注入和监听的所有内容。
+- [架构](../architecture.md):这些插件所处的系统地图。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml
new file mode 100644
index 0000000000..275c700851
--- /dev/null
+++ b/docs/cordis-tutorial/index.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+index.md: af622ad4e35829c6283c40f1b0019d7959dac973
+index.zh.md: 35bad552ecce9c0496b0ed88b041a8109c81945b
diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md
index 9cf3966117..af622ad4e3 100644
--- a/docs/cordis-tutorial/index.md
+++ b/docs/cordis-tutorial/index.md
@@ -1,5 +1,7 @@
# Cordis tutorial
+English | [中文](index.zh.md)
+
Cordis is the plugin framework underneath the DeepSeek Harness SDK: a small runtime where every capability — tools, LLM adapters, file access, the agent loop itself — is a plugin mounted into a shared context. This tutorial teaches Cordis hands-on: each chapter is a runnable example you build in a scratch directory inside this repository, ending with a plugin wired into real harness services.
The audience is agent developers. You do not need deep TypeScript experience; the [TypeScript notes](#typescript-notes) below explain the syntax that may be unfamiliar, and every chapter shows the exact commands and expected output.
@@ -41,6 +43,8 @@ That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js))
6. [Composition and HMR](06-composition-and-hmr.md) — the config file as a plugin tree, hot reload, and diagnosing a plugin that never loads.
7. [Into the harness](07-into-the-harness.md) — register a model-callable tool against real harness services.
+
+
## TypeScript notes
The examples use three TypeScript features beyond ordinary modern JavaScript:
diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md
new file mode 100644
index 0000000000..35bad552ec
--- /dev/null
+++ b/docs/cordis-tutorial/index.zh.md
@@ -0,0 +1,58 @@
+# Cordis 教程
+
+[English](index.md) | 中文
+
+Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行时,其中的每项能力,包括工具、LLM(大语言模型)适配器、文件访问乃至 agent loop(智能体循环)本身,都是挂载到共享上下文中的插件。本教程通过动手实践讲解 Cordis:每一章都是一个可以运行的示例,你将在本仓库内的临时目录中逐步构建它,最后把一个插件接入真实的 harness 服务。
+
+本教程面向 agent 开发者。你不需要深入掌握 TypeScript;下文的 [TypeScript 说明](#typescript-notes)会解释可能陌生的语法,并且每一章都会给出确切命令和预期输出。
+
+如果你想阅读精简的概念参考,而不是逐步实践,请参阅 [Cordis 入门](../cordis-primer.md)。详尽的 API 参考见生成的[事件](../cordis-catalog/events.md)与[服务](../cordis-catalog/services.md)目录,以及 [Cordis 核心 API](../cordis-catalog/core/context.md)页面。
+
+## 准备工作
+
+你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。
+
+```sh
+git clone https://github.com/deepseek-harness/deepseek-harness.git
+cd deepseek-harness
+pnpm install
+```
+
+创建各章使用的临时目录。`tmp/` 已被 git 忽略,因此你在其中写入的任何内容都不会进入版本控制:
+
+```sh
+mkdir -p tmp/cordis-tutorial
+cd tmp/cordis-tutorial
+```
+
+每一章都从该目录运行同一条命令:
+
+```sh
+node --import tsx ../../vendor/cordis/bin.js
+```
+
+这个单文件启动器(见 [vendor/cordis/bin.js](../../vendor/cordis/bin.js))会创建根 `Context`、挂载 Loader 插件,并让它从当前目录加载 `./cordis.yml`。其余所有内容,包括有哪些插件以及如何配置它们,都来自你稍后将编写的 YAML 文件。`--import tsx` 标志让 Node 无需构建步骤即可运行配置所指向的 TypeScript 文件。
+
+## 章节
+
+1. [你的第一个插件](01-first-plugin.md):插件是函数,由 loader 挂载。
+2. [生命周期与 effect](02-lifecycle-and-effects.md):由 Cordis 管理的注册会在所属插件卸载时撤销。
+3. [服务](03-services.md):在 `ctx` 上公开一项能力,并通过 `inject` 依赖它。
+4. [事件](04-events.md):类型化事件、广播分发和 waterfall(瀑布式事件)的短路行为。
+5. [配置](05-config.md):读取 `cordis.yml` 中经过校验的配置,并在输入错误时快速失败。
+6. [组合与 HMR(热模块替换)](06-composition-and-hmr.md):把配置文件作为插件树,使用热重载,并诊断始终无法加载的插件。
+7. [进入 harness](07-into-the-harness.md):基于真实的 harness 服务注册一个可由模型调用的工具。
+
+
+
+## TypeScript 说明
+
+这些示例使用了普通现代 JavaScript 之外的三项 TypeScript 功能:
+
+- **类型注解** 描述值,但不会改变运行时行为:`ctx: Context` 表示 `ctx` 具备 Cordis 上下文 API,`who: string` 接受文本,而 `string[]` 表示字符串数组。
+- **`import type { Context } from 'cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。
+- **声明合并**(`declare module 'cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。
+
+第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml
index 98855cdc0c..9d261fe939 100644
--- a/docs/core-data-structures/bash.i18n.yaml
+++ b/docs/core-data-structures/bash.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-bash.md: 35cf2061588907dde41123efb01e453eb9cc929d
-bash.zh.md: 0cfeb9e1a858f7057e720215c41a757588751122
+bash.md: 3747244662301a256e12037ea67c21017b5ac2c5
+bash.zh.md: 9927aa8d51ee410d70bed7a2d00e40061b499e15
diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md
index 35cf206158..3747244662 100644
--- a/docs/core-data-structures/bash.md
+++ b/docs/core-data-structures/bash.md
@@ -2,23 +2,13 @@
English | [中文](bash.zh.md)
-The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle.
+The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [subprocess seam](subprocess.md).
Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
## Managed shell environment namespace
-`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot.
-
-```ts type-equiv
-/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
-type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
-```
-
-```ts type-equiv
-/** Trusted DeepSeek Harness variables for one bash execution. */
-type DshEnvironment = Readonly>
-```
+`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the subprocess service removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`.
## Request vs. spec: the `resolve()` split
@@ -56,17 +46,18 @@ interface BashExecRequest {
stdin?: string | undefined
/**
* Ordinary environment entries for the command, merged after the credential
- * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it
- * here. Set by in-process plugins (the hooks bridges set
- * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool
- * does not expose it as a parameter.
+ * scrub. Managed facts belong in {@link dshEnv}, which merges after this
+ * map, so an entry here can never displace one. Set by in-process plugins
+ * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the
+ * model-facing bash tool does not expose it as a parameter.
*/
env?: Record | undefined
/**
- * Harness-owned `DSH_*` variables for this execution. Executors discard
- * ambient `DSH_*` entries before merging this snapshot, so an unavailable
- * current fact cannot inherit a stale value from the harness process, and
- * reject non-`DSH_*` names supplied through this managed channel.
+ * Harness-owned `DSH_*` variables for this execution (typed to managed
+ * keys). Executors discard ambient `DSH_*` entries before merging this
+ * snapshot last, so an unavailable current fact cannot inherit a stale
+ * value from the harness process and a caller {@link env} entry cannot
+ * displace a managed one.
*/
dshEnv?: DshEnvironment | undefined
/** Fully resolved per-call sandbox policy; sandboxing executors default it. */
@@ -95,12 +86,12 @@ interface BashExecSpec {
stdin?: string | undefined
/**
* Ordinary environment entries carried through from
- * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}.
+ * {@link BashExecRequest.env}; {@link dshEnv} still merges after them.
* OPTIONAL on the spec for the same reason as `stdin`: absent means no
* ordinary extra environment.
*/
env?: Record | undefined
- /** Managed `DSH_*` snapshot; implementations reject ordinary names. */
+ /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */
dshEnv?: DshEnvironment | undefined
/** Resolved sandbox policy; ignored by executors that do not confine. */
sandboxPolicy: SandboxExecutionPolicy | undefined
@@ -145,19 +136,7 @@ interface BashRunResult {
}
```
-Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file:
-
-```ts type-equiv
-/** One captured stream: the (possibly truncated) text plus recovery info. */
-interface CollectedOutput {
- /** Collected text — the TAIL of the stream when truncated. */
- text: string
- /** True when bytes were dropped from `text`. */
- truncated: boolean
- /** Path to a file holding the COMPLETE stream, when truncated and available. */
- spillPath?: string
-}
-```
+Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`.
## File sandbox: `BashSandboxInfo`
@@ -192,8 +171,9 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o
```ts type-equiv
/**
* A background process handle returned by {@link BashExecutor.start}. It is the
- * only access path; buffered output remains readable after exit. Executor
- * disposal kills running processes and awaits {@link done}.
+ * only access path; buffered output remains readable after exit. Composition
+ * teardown (the subprocess service's disposal) kills running processes and
+ * awaits {@link done}; an executor-only reload leaves them running.
*/
interface BashProcess {
/** Process lifecycle state (settled exactly once). */
@@ -238,4 +218,4 @@ interface BashProcessRead {
## The service
-`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns process groups, timeout/abort handling, bounded collectors, spill files, credential scrubbing, and disposal quiescence. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md).
+`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md).
diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md
index 0cfeb9e1a8..9927aa8d51 100644
--- a/docs/core-data-structures/bash.zh.md
+++ b/docs/core-data-structures/bash.zh.md
@@ -2,23 +2,13 @@
[English](bash.md) | 中文
-bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。
+bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](subprocess.md)之后。
源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
## 受管 shell 环境命名空间
-`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;执行器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。
-
-```ts type-equiv
-/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
-type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
-```
-
-```ts type-equiv
-/** Trusted DeepSeek Harness variables for one bash execution. */
-type DshEnvironment = Readonly>
-```
+`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。
## 请求与规格:`resolve()` 拆分
@@ -56,17 +46,18 @@ interface BashExecRequest {
stdin?: string | undefined
/**
* Ordinary environment entries for the command, merged after the credential
- * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it
- * here. Set by in-process plugins (the hooks bridges set
- * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool
- * does not expose it as a parameter.
+ * scrub. Managed facts belong in {@link dshEnv}, which merges after this
+ * map, so an entry here can never displace one. Set by in-process plugins
+ * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the
+ * model-facing bash tool does not expose it as a parameter.
*/
env?: Record | undefined
/**
- * Harness-owned `DSH_*` variables for this execution. Executors discard
- * ambient `DSH_*` entries before merging this snapshot, so an unavailable
- * current fact cannot inherit a stale value from the harness process, and
- * reject non-`DSH_*` names supplied through this managed channel.
+ * Harness-owned `DSH_*` variables for this execution (typed to managed
+ * keys). Executors discard ambient `DSH_*` entries before merging this
+ * snapshot last, so an unavailable current fact cannot inherit a stale
+ * value from the harness process and a caller {@link env} entry cannot
+ * displace a managed one.
*/
dshEnv?: DshEnvironment | undefined
/** Fully resolved per-call sandbox policy; sandboxing executors default it. */
@@ -95,12 +86,12 @@ interface BashExecSpec {
stdin?: string | undefined
/**
* Ordinary environment entries carried through from
- * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}.
+ * {@link BashExecRequest.env}; {@link dshEnv} still merges after them.
* OPTIONAL on the spec for the same reason as `stdin`: absent means no
* ordinary extra environment.
*/
env?: Record | undefined
- /** Managed `DSH_*` snapshot; implementations reject ordinary names. */
+ /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */
dshEnv?: DshEnvironment | undefined
/** Resolved sandbox policy; ignored by executors that do not confine. */
sandboxPolicy: SandboxExecutionPolicy | undefined
@@ -145,19 +136,7 @@ interface BashRunResult {
}
```
-每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息。截断时,`text` 是**尾部**,完整流溢出到一个私有文件:
-
-```ts type-equiv
-/** One captured stream: the (possibly truncated) text plus recovery info. */
-interface CollectedOutput {
- /** Collected text — the TAIL of the stream when truncated. */
- text: string
- /** True when bytes were dropped from `text`. */
- truncated: boolean
- /** Path to a file holding the COMPLETE stream, when truncated and available. */
- spillPath?: string
-}
-```
+每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。
## 文件沙箱:`BashSandboxInfo`
@@ -192,8 +171,9 @@ interface BashSandboxInfo {
```ts type-equiv
/**
* A background process handle returned by {@link BashExecutor.start}. It is the
- * only access path; buffered output remains readable after exit. Executor
- * disposal kills running processes and awaits {@link done}.
+ * only access path; buffered output remains readable after exit. Composition
+ * teardown (the subprocess service's disposal) kills running processes and
+ * awaits {@link done}; an executor-only reload leaves them running.
*/
interface BashProcess {
/** Process lifecycle state (settled exactly once). */
@@ -238,4 +218,4 @@ interface BashProcessRead {
## 服务
-`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有进程组、超时/中止处理、有界收集器、spill 文件、凭据清除以及 dispose(资源释放)后完全停稳。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。
+`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](subprocess.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。
diff --git a/docs/core-data-structures/commands.i18n.yaml b/docs/core-data-structures/commands.i18n.yaml
new file mode 100644
index 0000000000..ba55abec39
--- /dev/null
+++ b/docs/core-data-structures/commands.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+commands.md: 056c775f4c2e1586447db11821e5c7d56be01881
+commands.zh.md: 1a51305df356d8becf8c5517704dc375cdb8b585
diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md
index c36942cf18..056c775f4c 100644
--- a/docs/core-data-structures/commands.md
+++ b/docs/core-data-structures/commands.md
@@ -1,5 +1,7 @@
# Human Commands
+English | [中文](commands.zh.md)
+
The human-command seam of [`dsh-commands`](../../packages/ui/commands). Interactive adapters use it to discover and directly execute plugin-owned commands for an exact agent without creating a model message. The [command Agent Note](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns dispatch and lifecycle rationale; the [package README](../../packages/ui/commands/README.md) owns composition and limitations.
Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts)
diff --git a/docs/core-data-structures/commands.zh.md b/docs/core-data-structures/commands.zh.md
new file mode 100644
index 0000000000..1a51305df3
--- /dev/null
+++ b/docs/core-data-structures/commands.zh.md
@@ -0,0 +1,86 @@
+# 用户命令
+
+[English](commands.md) | 中文
+
+[`dsh-commands`](../../packages/ui/commands) 的用户命令 seam。交互式适配器用它发现插件拥有的命令,并针对确切的 agent(智能体)直接执行这些命令,而不创建模型消息。[命令 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) 负责分发与生命周期的决策依据;[包(package)README](../../packages/ui/commands/README.md) 负责组合方式与限制。
+
+来源:[`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts)
+
+## 输入元数据
+
+该 seam 公开一个可选的非结构化输入提示。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。
+
+```ts type-equiv
+/** Immutable metadata for a command's optional unstructured input. */
+interface CommandInputDescriptor {
+ /** Placeholder shown before the user supplies free-form input. */
+ readonly hint: string
+}
+```
+
+## 定义
+
+`CommandDefinition` 是由插件编写的注册定义。注册表会验证并冻结一份与原始注册对象脱离的生效定义。
+
+```ts type-equiv
+/** Plugin-owned command registration. */
+interface CommandDefinition {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Human-readable summary used in discovery UI. */
+ readonly description: string
+ /** Optional free-form input hint advertised to capable clients. */
+ readonly input?: CommandInputDescriptor
+ /** Execute against the receiving agent without sending the command to the model. */
+ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise
+}
+```
+
+## 调用与结果
+
+适配器拥有取消操作,并传入确切的目标 agent。`rawInput` 紧接在解析后的名称之后,并保留适配器传入的分隔符与后缀。结果会直接呈现给 UI,而不是工具结果或会话事件。
+
+```ts type-equiv
+/** Invocation passed to one registered command handler. */
+interface CommandInvocation {
+ /** Exact agent whose human-facing surface received the command. */
+ readonly agent: Agent
+ /** Exact text following the registered command name, including separator whitespace. */
+ readonly rawInput: string
+ /** Cancellation signal owned by the dispatching UI request. */
+ readonly signal: AbortSignal
+}
+```
+
+```ts type-equiv
+/** Expected command outcome rendered directly by the dispatching UI. */
+type CommandResult =
+ | { readonly kind: 'success'; readonly text?: string }
+ | { readonly kind: 'error'; readonly text: string }
+```
+
+## 发现与解析视图
+
+作用域解析后,适配器会获得不含处理器的不可变描述符。`parseCommand()` 在注册表解析前返回 `ParsedCommand`;语法有效的输入仍可能指向不可用的命令。
+
+```ts type-equiv
+/** Handler-free immutable command view returned to UI adapters. */
+interface CommandDescriptor {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Human-readable summary used in discovery UI. */
+ readonly description: string
+ /** Optional free-form input hint advertised to capable clients. */
+ readonly input?: CommandInputDescriptor
+}
+```
+
+```ts type-equiv
+/** Syntactically valid slash command before registry resolution. */
+interface ParsedCommand {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Exact text following the command name. */
+ readonly rawInput: string
+}
+```
diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml
index 47310dd09b..99db2eca3e 100644
--- a/docs/core-data-structures/core.i18n.yaml
+++ b/docs/core-data-structures/core.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write
-core.md: 781267cccdb5bbda33e5be6a9e807fdbe47dbc83
-core.zh.md: d0f67983b98b0cf679a8e599a5f8ab3c64490dd0
+# pnpm run verify-translation-pairing --write docs/core-data-structures/core.md
+core.md: 1fb3288a6d01860220191f0ee1f914804dd2b33e
+core.zh.md: 74ddc5f935c8138a7fdda601650f08702dae34d3
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index 781267cccd..1fb3288a6d 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -31,6 +31,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts |
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles |
+| [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary |
| [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots |
| [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors |
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
@@ -199,7 +200,7 @@ interface LlmModelInfo {
}
```
-Correctness-sensitive model capacity is queried separately from the advisory catalog and is owned by the adapter serving the exact route.
+Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution.
```ts type-equiv
/** Provider-owned context capacity for one exact provider/model route. */
@@ -209,12 +210,56 @@ interface LlmModelContext {
}
```
+Reasoning effort is another exact-route capability. The core brands identifiers but does not enumerate their values; each adapter owns the ordered set, display names, and optional deployment default.
+
+```ts type-equiv
+/** Adapter-owned identifier for one model's selectable reasoning effort. */
+type ReasoningEffortId = Branded<'ReasoningEffortId'>
+```
+
+```ts type-equiv
+/** Display metadata for one adapter-owned reasoning effort. */
+interface LlmReasoningEffortInfo {
+ /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */
+ id: ReasoningEffortId
+ /** Human-readable effort name for selectors and diagnostics. */
+ name: string
+ /** Optional user-facing distinction from otherwise similar efforts. */
+ description?: string
+}
+```
+
+```ts type-equiv
+/** Selectable reasoning efforts for one exact provider/model route. */
+interface LlmModelReasoningInfo {
+ /** Supported efforts in adapter-preferred display order. */
+ efforts: readonly LlmReasoningEffortInfo[]
+ /**
+ * Adapter-configured default materialized into requests when callers omit
+ * an effort. Absence preserves the provider's own default.
+ */
+ defaultEffort?: ReasoningEffortId
+}
+```
+
+```ts type-equiv
+/** Exact-route model metadata resolved by its owning adapter. */
+interface LlmResolvedModelInfo extends LlmModelInfo {
+ /** Provider-owned context capacity when known. */
+ context?: LlmModelContext
+ /** Adapter-owned selectable reasoning levels when exposed. */
+ reasoning?: LlmModelReasoningInfo
+}
+```
+
```ts type-equiv
/** A single model request, fully assembled. */
interface GenerateOptions {
/** Registered provider route selecting the adapter instance. */
provider: string
model: string
+ /** Adapter-owned reasoning effort selected for this exact model. */
+ reasoningEffort?: ReasoningEffortId
/**
* Ordered conversation messages, exactly as the provider sees them (after
* the `system` slot). A loop-built request assembles them as
@@ -291,21 +336,23 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition`
The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
-`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, or sampling. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
+`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request.
-FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them.
+FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution).
```ts type-equiv
/**
- * Provider + model + sampling scalars of one conversation's requests. Every field maps
- * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests
- * from the logged header rather than accepting these per call.
+ * Provider, model, reasoning effort, and sampling scalars of one conversation's
+ * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field;
+ * the loop builds requests from the logged header rather than accepting these
+ * per call.
*/
interface LlmCallConfig {
provider: string
model: string
+ reasoningEffort?: ReasoningEffortId
temperature?: number
maxTokens?: number
stop?: string[]
diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md
index d0f67983b9..74ddc5f935 100644
--- a/docs/core-data-structures/core.zh.md
+++ b/docs/core-data-structures/core.zh.md
@@ -31,6 +31,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数
| [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 |
| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 |
| [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 |
+| [subprocess.md](subprocess.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 |
| [pty.md](pty.md) | 持久化终端 ID、后端/会话契约、发送就绪状态、有界读取与 owner 可见快照 |
| [sandbox.md](sandbox.md) | 每会话策略解析与进程约束 seam:文件效果模式、执行/提供方策略、`ConfinedArgv`、强制执行与故障关闭错误 |
| [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 |
@@ -205,7 +206,7 @@ interface LlmModelInfo {
}
```
-对正确性敏感的模型容量与参考目录分开查询,并归服务该确切路由的适配器所有。
+对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。
```ts type-equiv
/** Provider-owned context capacity for one exact provider/model route. */
@@ -215,12 +216,56 @@ interface LlmModelContext {
}
```
+推理强度是另一项针对确切路由的能力。核心为标识符添加品牌类型,但不枚举其值;有序集合、展示名称和可选的部署默认值均由各适配器持有。
+
+```ts type-equiv
+/** Adapter-owned identifier for one model's selectable reasoning effort. */
+type ReasoningEffortId = Branded<'ReasoningEffortId'>
+```
+
+```ts type-equiv
+/** Display metadata for one adapter-owned reasoning effort. */
+interface LlmReasoningEffortInfo {
+ /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */
+ id: ReasoningEffortId
+ /** Human-readable effort name for selectors and diagnostics. */
+ name: string
+ /** Optional user-facing distinction from otherwise similar efforts. */
+ description?: string
+}
+```
+
+```ts type-equiv
+/** Selectable reasoning efforts for one exact provider/model route. */
+interface LlmModelReasoningInfo {
+ /** Supported efforts in adapter-preferred display order. */
+ efforts: readonly LlmReasoningEffortInfo[]
+ /**
+ * Adapter-configured default materialized into requests when callers omit
+ * an effort. Absence preserves the provider's own default.
+ */
+ defaultEffort?: ReasoningEffortId
+}
+```
+
+```ts type-equiv
+/** Exact-route model metadata resolved by its owning adapter. */
+interface LlmResolvedModelInfo extends LlmModelInfo {
+ /** Provider-owned context capacity when known. */
+ context?: LlmModelContext
+ /** Adapter-owned selectable reasoning levels when exposed. */
+ reasoning?: LlmModelReasoningInfo
+}
+```
+
```ts type-equiv
/** A single model request, fully assembled. */
interface GenerateOptions {
/** Registered provider route selecting the adapter instance. */
provider: string
model: string
+ /** Adapter-owned reasoning effort selected for this exact model. */
+ reasoningEffort?: ReasoningEffortId
/**
* Ordered conversation messages, exactly as the provider sees them (after
* the `system` slot). A loop-built request assembles them as
@@ -297,21 +342,23 @@ interface ToolSchema {
循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
-`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
+`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。
-FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。
+FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。
```ts type-equiv
/**
- * Provider + model + sampling scalars of one conversation's requests. Every field maps
- * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests
- * from the logged header rather than accepting these per call.
+ * Provider, model, reasoning effort, and sampling scalars of one conversation's
+ * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field;
+ * the loop builds requests from the logged header rather than accepting these
+ * per call.
*/
interface LlmCallConfig {
provider: string
model: string
+ reasoningEffort?: ReasoningEffortId
temperature?: number
maxTokens?: number
stop?: string[]
diff --git a/docs/core-data-structures/goal.i18n.yaml b/docs/core-data-structures/goal.i18n.yaml
new file mode 100644
index 0000000000..47dc0c1b1c
--- /dev/null
+++ b/docs/core-data-structures/goal.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+goal.md: 2e8d296eeda6e5f69c0f92829e347b7f55f41fa9
+goal.zh.md: a9c946e7cd37cf948c7ac0f3e4d0ea35ac80d614
diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md
index c0351f2f77..2e8d296eed 100644
--- a/docs/core-data-structures/goal.md
+++ b/docs/core-data-structures/goal.md
@@ -1,5 +1,7 @@
# Same-session goals
+English | [中文](goal.zh.md)
+
Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts).
## Identity and lifecycle
diff --git a/docs/core-data-structures/goal.zh.md b/docs/core-data-structures/goal.zh.md
new file mode 100644
index 0000000000..a9c946e7cd
--- /dev/null
+++ b/docs/core-data-structures/goal.zh.md
@@ -0,0 +1,145 @@
+# 同会话目标
+
+[English](goal.md) | 中文
+
+事件溯源目标领域及其策略消费方共享的类型。[目标领域 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的字面形态。
+
+## 标识与生命周期
+
+`GoalId` 是[品牌化 id](core.md#branded-ids)。调用方通过 `GoalRef` 修改一个确切修订版本;每次获准的持久变更都会递增修订号。
+
+```ts type-equiv
+/** Compare-and-set identity for one exact goal revision. */
+interface GoalRef {
+ /** Stable goal identity. */
+ readonly id: GoalId
+ /** Positive revision; every durable mutation increments it. */
+ readonly revision: number
+}
+```
+
+持久阶段回答目标发生了什么。进程本地激活状态则另行回答续跑消费方能否开始另一个 Round。
+
+```ts type-equiv
+/** Durable continuation phase. Activation is process-local and separate. */
+type GoalPhase =
+ | 'active'
+ | 'paused'
+ | 'blocked'
+ | 'complete'
+```
+
+阻塞是唯一表示「因问题而停止」的持久状态。由策略负责的阻塞原因会携带一个用于路由、稳定且采用 lower-kebab-case 的代码,以及一段供人和模型阅读的自由文本说明。
+
+```ts type-equiv
+/** Machine-routable and human-readable explanation for a blocked goal. */
+interface GoalBlockReason {
+ /** Stable lower-kebab-case classification chosen by the blocking policy. */
+ readonly code: string
+ /** Non-empty explanation shown to humans and models. */
+ readonly message: string
+}
+```
+
+```ts type-equiv
+/** Full durable state written by every non-clear goal mutation. */
+interface GoalSnapshot extends GoalRef {
+ /** Human-requested completion objective. */
+ readonly objective: string
+ /** Durable lifecycle phase. */
+ readonly phase: GoalPhase
+ /** Present exactly while `phase` is `blocked`. */
+ readonly blockedReason?: GoalBlockReason
+ /** Total admitted goal-round cap. */
+ readonly maxGoalRounds: number
+}
+```
+
+```ts type-equiv
+/** Current goal projection, including values derived from the session log. */
+interface GoalView extends GoalSnapshot {
+ /** Highest admitted round number for this goal. */
+ readonly roundsStarted: number
+ /** Epoch milliseconds of the create mutation. */
+ readonly createdAt: number
+ /** Epoch milliseconds of the latest mutation. */
+ readonly updatedAt: number
+ /** Process-local continuation eligibility; never persisted. */
+ readonly activation: GoalActivation
+}
+```
+
+## 持久变更
+
+每次变更都是 Round 编号为 0、来源为目标的 `user/message`,其元数据要么是完整快照,要么是清除墓碑。版本、元数据、目标来源和逐字渲染内容共同构成一项回放不变量。
+
+```ts type-equiv
+/** Full-snapshot goal mutation retained in a model-visible context event. */
+interface GoalSnapshotChangeMeta {
+ readonly kind: 'goal/change'
+ readonly version: 1
+ readonly operation: Exclude
+ readonly goal: GoalSnapshot
+ readonly roundsStarted: number
+ readonly createdAt: number
+ readonly updatedAt: number
+}
+```
+
+```ts type-equiv
+/** Tombstone retained when the current goal is cleared. */
+interface GoalClearChangeMeta {
+ readonly kind: 'goal/change'
+ readonly version: 1
+ readonly operation: 'clear'
+ readonly cleared: GoalRef
+ readonly clearedAt: number
+}
+```
+
+目标状态变更使用 Round `0`。续跑消费方会为每个获准的用户消息轮次标注正数且连续的 Round 编号和当前修订号;回放会拒绝编号缺口、陈旧修订号、已停止阶段和超出上限。
+
+```ts type-equiv
+/** Message attribution for durable goal state and continuation rounds. */
+interface GoalMessageSource {
+ readonly kind: 'goal'
+ readonly goalId: GoalId
+ readonly revision: number
+ /** Zero for state changes; positive for admitted continuation rounds. */
+ readonly round: number
+}
+```
+
+## 请求与通知
+
+创建操作会区分调用方省略的值与部署选择,`create()` 会在内部解析后者。编辑是局部替换,其运行时校验器要求至少提供一个字段。每条变更通知都会携带获准的操作和确切修订号;清除操作不带 `goal`。
+
+```ts type-equiv
+/** Input whose omitted round cap is resolved by the service configuration. */
+interface CreateGoalRequest {
+ readonly objective: string
+ readonly maxGoalRounds?: number
+}
+```
+
+```ts type-equiv
+/** Fields changed by an edit; at least one must be present. */
+interface EditGoalRequest {
+ readonly objective?: string
+ readonly maxGoalRounds?: number
+}
+```
+
+```ts type-equiv
+/** Live notification after one goal mutation has been accepted for logging. */
+interface GoalChanged {
+ readonly operation: GoalOperation
+ readonly ref: GoalRef
+ /** Absent for a clear tombstone. */
+ readonly goal?: GoalView
+}
+```
+
+## 服务行为
+
+[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更、叠加延迟注入,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用契约和面向模型的契约。
diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml
index 0ef4310fd7..7f12b17ede 100644
--- a/docs/core-data-structures/llm-streaming.i18n.yaml
+++ b/docs/core-data-structures/llm-streaming.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write
-llm-streaming.md: 207050f93cf114b18dd3b3c6f1630d0204cf3848
-llm-streaming.zh.md: 0441fac7e1b1ef9df8d4a467febf2e175dc7b5db
+# pnpm run verify-translation-pairing --write docs/core-data-structures/llm-streaming.md
+llm-streaming.md: 6bbfcf8162225bae85f2073e49b04dd861790bfe
+llm-streaming.zh.md: 5ed623eced517debe0aa5d31bdc8f07c6f59d762
diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md
index 207050f93c..6bbfcf8162 100644
--- a/docs/core-data-structures/llm-streaming.md
+++ b/docs/core-data-structures/llm-streaming.md
@@ -67,7 +67,7 @@ Every adapter MUST obey these, and every consumer may rely on them:
- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter).
- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state.
-This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request.
+This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (direct fetch, SSE framing via `eventsource-parser`) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request.
## `ResolvedRetryPolicy`
@@ -161,14 +161,30 @@ declare class BlockAssembler {
## The seam
-`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
+`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
+
+```ts type-equiv
+/** One model call whose config and adapter registration were resolved together. */
+interface PreparedLlmCall {
+ /** Detached, deep-frozen config with any adapter-owned default materialized. */
+ readonly config: LlmCallConfig
+ /**
+ * Dispatch this call once through the registration captured during
+ * preparation. The request's call-config fields must match {@link config};
+ * reuse or mismatch fails with `INVALID_PREPARED_CALL`.
+ * @param options - fully assembled request carrying the prepared config.
+ * @returns the chunk stream, including the `llm/stream` waterfall.
+ */
+ stream(options: GenerateOptions): AsyncIterable