From 2e52c0670cea63933eaae0104410faab454c31d3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:26:41 +0800 Subject: [PATCH 01/19] feat(llm-replay): indexed override patches for error injection The override sidecar now accepts { patches: [{ at, entry }] } alongside the legacy whole-script ReplayEntry[] replacement: the JSONL-derived script is kept and only the named call indexes are swapped (at == length appends, for a retry attempt following an injected transient throw). Out-of-range or non-integer indexes fail loud with the derived length in the diagnostic. This is the mock-LLM error capability the web e2e scenarios drive: 'call N throws AUTH/SERVER, everything else replays as recorded'. --- docs/config-catalog.md | 2 +- packages/support/llm-replay/README.md | 2 +- packages/support/llm-replay/src/index.ts | 61 +++++++++++++++---- .../llm-replay/tests/llm-replay.spec.ts | 47 +++++++++++++- 4 files changed, 97 insertions(+), 15 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d437649d9b..b28634ccae 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -688,7 +688,7 @@ export interface ReplayModelConfig { } ``` -Source: [`packages/support/llm-replay/src/index.ts:459`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:496`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index a8d811f35b..50c4976b8d 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -8,7 +8,7 @@ Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stre The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` events and the line-0 session header. -Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`: a `ReplayEntry[]`) that REPLACES the derived script. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. +Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either REPLACES the derived script (a bare `ReplayEntry[]`) or AUGMENTS it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call, swap only the named 0-based call indexes; `at` equal to the derived length appends — the slot for the retry attempt that follows an injected transient throw). A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. ## Nested agents: per-session keying diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index f9637536f8..e54eabc4b6 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -200,26 +200,63 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { } /** - * Build the replay script for the PRIMARY session: the sidecar override if - * present, otherwise the script derived from the recorded session JSONL. - * Fail-loud if the JSONL fixture is missing (the scenario was never recorded) — - * never silently returns an empty script, so a coverage hole can't masquerade - * as a passing replay. + * One positional patch in an augmentation sidecar: replaces the derived + * entry at call index `at` (0-based) with `entry`, or appends when `at` + * equals the derived length (an extra recorded-after-the-fact call, e.g. the + * retry attempt following an injected transient throw). + */ +export interface ReplayOverridePatch { + /** 0-based call index into the derived script; == length appends. */ + at: number + /** The replacement (or appended) entry at that call position. */ + entry: ReplayEntry +} + +/** + * Override sidecar document: either the legacy whole-script replacement (a + * bare `ReplayEntry[]`) or the augmentation form `{ patches }`, which keeps + * the JSONL-derived script and swaps only the named call indexes — the shape + * for "turn N errors, everything else replays as recorded". + */ +export type ReplayOverrideDoc = ReplayEntry[] | { patches: ReplayOverridePatch[] } + +/** + * Load the PRIMARY session's replay script: the sidecar override when present + * (whole-script replacement or `{ patches }` augmentation over the derived + * script), else the script derived from the session JSONL (fail-loud when the + * fixture is missing). * @param config - the fixture paths; only `file` and `overrideFile` are consulted. - * @returns the primary session's replay entries. + * @returns the resolved primary-session script. */ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { if (config.overrideFile !== undefined && existsSync(config.overrideFile)) { const parsed: unknown = JSON.parse(readFileSync(config.overrideFile, 'utf8')) - if (!Array.isArray(parsed)) { - throw new Error(`llm-replay: override is not a JSON array: ${config.overrideFile}`) + if (Array.isArray(parsed)) return parsed as ReplayEntry[] + const doc = parsed as { patches?: unknown } + if (typeof parsed !== 'object' || parsed === null || !Array.isArray(doc.patches)) { + throw new Error(`llm-replay: override must be a ReplayEntry[] or { patches: [...] }: ${config.overrideFile}`) } - return parsed as ReplayEntry[] + const script = deriveScriptFromFile(config.file) + for (const patch of doc.patches as ReplayOverridePatch[]) { + if (!Number.isInteger(patch.at) || patch.at < 0 || patch.at > script.length) { + throw new Error( + `llm-replay: override patch index ${String(patch.at)} out of range ` + + `(derived script has ${script.length} call(s); == length appends): ${config.overrideFile}`, + ) + } + script[patch.at] = patch.entry + } + return script } - if (!existsSync(config.file)) { - throw new Error(`llm-replay: fixture not found: ${config.file} — run \`pnpm run test:snapshot:record\` first`) + return deriveScriptFromFile(config.file) +} + +/** Derive the primary script from the session JSONL, failing loud on a missing fixture. */ +function deriveScriptFromFile(file: string): ReplayEntry[] { + if (!existsSync(file)) { + throw new Error(`llm-replay: fixture not found: ${file} — run \`pnpm run test:snapshot:record\` first`) } - return deriveReplayScript(parseSessionLog(readFileSync(config.file, 'utf8'))) + return deriveReplayScript(parseSessionLog(readFileSync(file, 'utf8'))) } /** diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 584a87abf6..1bd8d47405 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -207,7 +207,52 @@ describe('loadReplayScript', () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') writeFileSync(overrideFile, '{"not":"array"}', 'utf8') - expect(() => loadReplayScript({ file, overrideFile })).toThrow(/not a JSON array/) + expect(() => loadReplayScript({ file, overrideFile })).toThrow(/ReplayEntry\[\] or \{ patches/) + }) + + it('patches form: swaps the named call index and keeps derived siblings', () => { + const callB: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'two' }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + let seq = 1 + writeFileSync(file, sessionJsonl([ + ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)), + ...callB.map(c => chunkEvent(seq++, 1, 2, c)), + ]), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify({ + patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'transient', code: 'SERVER' } }], + }), 'utf8') + expect(loadReplayScript({ file, overrideFile })).toEqual([ + { kind: 'throw', chunks: [], message: 'transient', code: 'SERVER' }, + { kind: 'chunks', chunks: callB }, + ]) + }) + + it('patches form: at == derived length appends (the retry-attempt slot)', () => { + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify({ + patches: [ + { at: 0, entry: { kind: 'throw', chunks: [], message: '429', code: 'RATE_LIMIT' } }, + { at: 1, entry: { kind: 'chunks', chunks: TEXT_CHUNKS } }, + ], + }), 'utf8') + expect(loadReplayScript({ file, overrideFile })).toEqual([ + { kind: 'throw', chunks: [], message: '429', code: 'RATE_LIMIT' }, + { kind: 'chunks', chunks: TEXT_CHUNKS }, + ]) + }) + + it('patches form: an out-of-range index fails loud with the derived length', () => { + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + for (const at of [2, -1, 1.5]) { + writeFileSync(overrideFile, JSON.stringify({ patches: [{ at, entry: { kind: 'hang' } }] }), 'utf8') + expect(() => loadReplayScript({ file, overrideFile })).toThrow(/patch index .* out of range/) + } }) }) From bb0bcf62504c8e483b0d71aba900e30e439881ef Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:28:13 +0800 Subject: [PATCH 02/19] fix(llm): honor a carried failure snapshot on any Error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markLlmAdapterFailure gated the own-`failure` data property on instanceof HarnessError, which drops the validated facts exactly when class identity is lost — two copies of this package in one process (e.g. a source-plane replay harness throwing into a lib-plane boot) make the replay-thrown LlmError's SERVER/AUTH code arrive as UNKNOWN and defeat llm-retry's retryable-code match. The snapshot is already validated field-by-field and cross-checked against the error's own code, so honor it on any Error. --- packages/llm/llm/src/adapter-failure.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index 390282327d..8da17807fa 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -47,7 +47,12 @@ export function markLlmAdapterFailure( const error = value instanceof Error ? value as Error & { code?: string } : new HarnessError(String(value), 'UNKNOWN', { cause: value }) - const carried = error instanceof HarnessError ? ownFailureSnapshot(error) : undefined + // The own `failure` data property is the serializable boundary contract: + // validated field-by-field and cross-checked against the error's own code, + // then honored on ANY Error — an instanceof gate here would drop the facts + // exactly when class identity is lost (a second copy of this package in + // the process, e.g. a source-plane test harness over a lib-plane boot). + const carried = ownFailureSnapshot(error) const failure = carried !== undefined && carried.code === error.code ? carried : Object.freeze({ message: errorMessage(error), code: harnessErrorCode(error), From 2828e0462d66feeee006eb97417caa868120962b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:31:20 +0800 Subject: [PATCH 03/19] feat(web): mount llm-retry in the shipped web composition The web tree had no transient-failure recovery around the loop's model calls; the TUI agent-spine composition already mounts llm-retry. Same defaults (2 retries, 500ms->10s backoff). The browser e2e retry scenario drives it end-to-end: an injected SERVER throw at call 0 recovers through the durable llm/retry record and completes in the transcript. --- apps/cli/cordis.yml | 5 +++++ apps/cli/package.json | 1 + pnpm-lock.yaml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b89df77c46..f3f03c388f 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -67,6 +67,11 @@ apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL +# Transient-failure recovery around the loop's model calls (same policy as +# the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff). +- id: llm-retry + name: '@deepseek-ai/dsh-llm-retry' + - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: diff --git a/apps/cli/package.json b/apps/cli/package.json index e1c07f90b5..6cf696f542 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19dcce27..0e1bb2260f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../packages/llm/llm-retry '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths From 90d91c3cf9dbb41739443d83edac682f62d1d806 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:31:21 +0800 Subject: [PATCH 04/19] docs(llm-replay): cover the patches form in the overrideFile contract The ReplayConfig.overrideFile JSDoc still described only whole-script replacement; it now names both sidecar forms and links ReplayOverrideDoc (config catalog regenerated: source line shifted). --- docs/config-catalog.md | 2 +- packages/support/llm-replay/src/index.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b28634ccae..fa4aaecc31 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -688,7 +688,7 @@ export interface ReplayModelConfig { } ``` -Source: [`packages/support/llm-replay/src/index.ts:496`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:497`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index e54eabc4b6..4eb042d4f5 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -59,10 +59,11 @@ export interface ReplayConfig { */ file: string /** - * Optional `ReplayEntry[]` sidecar that REPLACES the derived script for the - * PRIMARY session. Used by the two single-session scenarios not expressible as - * `assistant/chunk` (pure throw-before-chunk, cancel/hang). Absent for normal - * and nested scenarios. + * Optional sidecar for the PRIMARY session: a bare `ReplayEntry[]` REPLACES + * the derived script; `{ patches }` keeps it and swaps the named call + * indexes ({@link ReplayOverrideDoc}). Used by single-session scenarios not + * expressible as `assistant/chunk` (throw-before-chunk, cancel/hang, + * injected transient failures). Absent for normal and nested scenarios. */ overrideFile?: string /** From 04b7f517aebc6526d697a0c9b5b625bac73f2472 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:32:17 +0800 Subject: [PATCH 05/19] =?UTF-8?q?test(web):=20live-turn=20interaction=20sc?= =?UTF-8?q?enarios=20=E2=80=94=20cancel,=20error,=20retry,=20question=20co?= =?UTF-8?q?mposer,=20steering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five browser e2e scenarios over the existing keyless lane, one recorded base fixture per spec family: - live-interactions: one tool-free recorded turn + per-run override sidecars authored in the spec (content single-sourced from the fixture via deriveReplayScript, minted into a spec-owned temp dir). Cancel uses a hang patch with a readyFile marker — the marker proves the stream is parked mid-turn before the Stop click, so mid-stream cancellation is deterministic by construction (turn/end 'aborted', composer re-enabled). AUTH pins the non-retryable path: turn/end 'error', zero llm/retry events, composer recovers; FIXME(web-error-surface) marks the found product gap (no error copy renders — the client consumes no agent/error frames and a pre-chunk failure freezes no partial). SERVER retry appends the fixture's own success after an injected throw and proves llm-retry end-to-end in the browser via the durable llm/retry record. - question-composer: the shipped ask_user_question takeover blocks the turn mid-step on the real userInteraction seam; the test answers through the composer (the one sanctioned model-content-reactive drive step: the turn cannot complete without it) and the tool result carries the answer. Adds the composer waiting-state aria golden. - steering: steers mid-turn while the composer blocks the step (the deterministic mid-turn window). The steer rides the real wire (session.prompt mode:'steer' POSTed from the page; the locked composer has no steering gesture yet — TODO(web-steer-composer)); downstream is all product: gateway -> Agent.steer -> step-boundary drain -> durable steering/message -> SSE -> badged interjection bubble. Record mode rejects a fixture whose live reply ignored the steer. Scaffold gains the replayOverride passthrough; specs register in both tsconfig planes (client exclude, host include). --- apps/web/tests/live-interactions.e2e.ts | 176 ++++++++++++++++++ apps/web/tests/question-composer.e2e.ts | 99 ++++++++++ apps/web/tests/scaffold.ts | 7 + .../snapshots/live-interactions/session.jsonl | 93 +++++++++ .../snapshots/question-composer/session.jsonl | 147 +++++++++++++++ .../question-composer/ui.expected.md | 23 +++ .../tests/snapshots/steering/session.jsonl | 144 ++++++++++++++ apps/web/tests/steering.e2e.ts | 146 +++++++++++++++ apps/web/tsconfig.json | 3 + tsconfig.host.json | 3 + 10 files changed, 841 insertions(+) create mode 100644 apps/web/tests/live-interactions.e2e.ts create mode 100644 apps/web/tests/question-composer.e2e.ts create mode 100644 apps/web/tests/snapshots/live-interactions/session.jsonl create mode 100644 apps/web/tests/snapshots/question-composer/session.jsonl create mode 100644 apps/web/tests/snapshots/question-composer/ui.expected.md create mode 100644 apps/web/tests/snapshots/steering/session.jsonl create mode 100644 apps/web/tests/steering.e2e.ts diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts new file mode 100644 index 0000000000..632dc79085 --- /dev/null +++ b/apps/web/tests/live-interactions.e2e.ts @@ -0,0 +1,176 @@ +// 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, fixtureUserPrompts, launchWebScaffold, recordFixture, + watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +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 () => { + await browser?.close().catch(() => undefined) + browser = undefined + await scaffold?.close().catch(() => undefined) + scaffold = undefined + if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch(() => undefined) + sidecarDir = undefined + }) + + /** 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 }) + } + + /** + * 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. + await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + expect(await page.locator('[data-streaming="true"]').count()).toBe(0) + expect(tripwire.pageErrors).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) + expect(tripwire.pageErrors).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) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) + }) +}) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts new file mode 100644 index 0000000000..9678a7a648 --- /dev/null +++ b/apps/web/tests/question-composer.e2e.ts @@ -0,0 +1,99 @@ +// 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 { 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') +const MODE = webSnapshotMode() + +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 options labeled "Blue" and "Green". 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 }) + }, 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') { + // Golden of the composer's waiting state (the transcript region golden + // is #612's job; this pins the question surface). + const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + } + + 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) + expect(tripwire.pageErrors).toEqual([]) + }, 200_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index d858e0f7ad..98fe05f0ca 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -101,6 +101,12 @@ 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 } @@ -179,6 +185,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 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 }) + }, 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) + + // 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) + expect(tripwire.pageErrors).toEqual([]) + }, 200_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 54c5673451..fa92bde8ea 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,9 @@ // 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/replay-round-trip.e2e.ts", "tests/seeded-history.e2e.ts" ], diff --git a/tsconfig.host.json b/tsconfig.host.json index 7386119274..a6e24f2a52 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -10,6 +10,9 @@ "include": [ "apps/web/tests/scaffold.ts", "apps/web/tests/support.ts", + "apps/web/tests/live-interactions.e2e.ts", + "apps/web/tests/question-composer.e2e.ts", + "apps/web/tests/steering.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "examples/*/src/**/*.ts", From 96c67df835706ead1e7ea7b58ab317d0005e0e2a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:07:49 +0800 Subject: [PATCH 06/19] docs(notes): extend the web e2e lane note with the live-interaction scenarios Both languages: the three new scenarios (live-interactions overrides, question-composer takeover, wire-level steering), the product-delta list ({ patches } override form, the carried-failure fix, the llm-retry row), two new Deferred items (web error surface, composer steering gesture), and de-hardcoded scenario counts; pairing re-recorded. --- .../2026-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.md | 11 ++++++++--- .../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md | 11 ++++++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index e50541fa85..0efac9b250 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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 -2026-07-24-web-gui-browser-e2e-lane.md: b6e62f59e12c64dd5386eaaabe15e863ef52e291 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 9f806c7030336bad4f7a9ca7695a03982d8a7878 +2026-07-24-web-gui-browser-e2e-lane.md: 796c0812b91f059e52fc82238802bd12e1e3a93f +2026-07-24-web-gui-browser-e2e-lane.zh.md: 3e725b92cda3beaf47f6c3d3f8dfb2b02dc1ae7e diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index b6e62f59e1..796c0812b9 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -10,7 +10,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin ## Decision -`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replayed through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, asserting a normalized conversation aria golden plus in-process world state. No new package; the product deltas are two additive `dsh-llm-replay` surfaces (`paceMs`, `ReplayHandle`). +`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replayed through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, asserting a normalized conversation aria golden plus in-process world state. No new package; the product deltas are additive `dsh-llm-replay` surfaces (`paceMs`, `ReplayHandle`, and the `{ patches }` override form: indexed augmentation over the derived script so a sidecar expresses "call N throws / hangs, everything else replays as recorded" without copying recorded chunks), one `dsh-llm` fix the retry scenario exposed (a carried `failure` snapshot is honored on any Error — the `instanceof` gate dropped provider codes across dual package copies, source-plane replay over a lib-plane boot), and the `llm-retry` row the web composition was missing. ### Scaffold: `apps/web/tests/scaffold.ts` @@ -38,12 +38,15 @@ The typecheck plane split is structural: `apps/web/tests/{scaffold,support,repla ### Modes and fixtures -`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}` tokenization; a follow-up keyless refresh regenerates `ui.expected.md`. Both scenarios' fixtures were recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred. +`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}` tokenization; a follow-up keyless refresh regenerates `ui.expected.md`. Every prompting scenario's fixture was recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred. ### Scenarios 1. **`replay-round-trip`** — new session, prompt through the real composer, replay streams reasoning + a `bash` tool call that really executes in the temp workspace + final text (paced 15ms). Asserts settled markdown, the aria golden, and inline world state (bash `tool/call`, completed `turn/end`, >10 chunk events). 2. **`seeded-history`** — a recorded session seeded cold; the sidebar lists it (group row → session row, collapsed by default), opening renders tool cards and text purely from the log through the implicit cold-resume attach inside `session.history` — zero model calls in replay, so no binding constraints; record mode drives the same turn live (real `read` tool against seeded workspace files) to produce the seed. +3. **`live-interactions`** — one tool-free recorded turn serves three replay-only scenarios through override sidecars whose CONTENT is authored in the spec and minted as a per-run file in a spec-owned temp dir (the derived success entry for the retry append is re-derived from the fixture via `deriveReplayScript`, never copied into a committed sidecar). Cancel: a `{ patches }` `hang` with a `readyFile` marker — the marker's existence proves the stream is parked mid-turn before the test clicks Stop, making mid-stream cancellation deterministic by construction (`turn/end` reason `aborted`, composer re-enabled). AUTH error: a pre-chunk `throw` outside llm-retry's retryable set (`turn/end` reason `error`, zero `llm/retry` events, composer recovers). SERVER retry: `throw` at call 0 + the fixture's own success appended at 1, proving llm-retry end-to-end in the browser via the durable `llm/retry` record (`request/header` logs only on change, so attempt count is invisible there). +4. **`question-composer`** — the shipped composition's resident `ask_user_question` takeover: a recorded turn blocks mid-step on the real userInteraction seam, the composer (`[data-question-key]`) renders in the browser, the test answers through it (the ONE sanctioned place a drive step reacts to model content: the turn cannot complete without the answer, in record and replay alike), and the tool result carries the chosen label. Golden: the composer's stable waiting state. +5. **`steering`** — mid-turn steer while the question composer blocks the step (the deterministic mid-turn window; no timing dependence). The composer locks while running, so the steer POSTs `session.prompt` `mode:'steer'` from the page over the same same-origin `/api` wire the client uses (`TODO(web-steer-composer)`: drive a composer gesture once one exists); everything downstream is product — gateway → `Agent.steer` → step-boundary drain → durable `steering/message` → SSE → badged interjection bubble. Record-mode fixture honesty: the recording is rejected unless the live model's final reply obeys an instruction only the steering message carries. ### CI stance @@ -77,13 +80,15 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot ## Testing -The lane itself: `pnpm run test:web` runs both scenarios keylessly alongside the existing smoke pair; `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` re-records a scenario's fixture against the live model; `DSH_SNAPSHOT=refresh` rewrites both aria goldens keylessly. `paceMs` validation, pacing floor, abort-during-pace, and both `assertConsumed` failure shapes are pinned in `packages/support/llm-replay/tests/llm-replay.spec.ts`. +The lane itself: `pnpm run test:web` runs every scenario keylessly alongside the existing smoke pair; `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` re-records a scenario's fixture against the live model; `DSH_SNAPSHOT=refresh` rewrites the aria goldens keylessly. `paceMs` validation, pacing floor, abort-during-pace, both `assertConsumed` failure shapes, and the `{ patches }` acceptance/rejection paths (index swap keeps siblings, `at == length` appends, out-of-range/non-integer loud) are pinned in `packages/support/llm-replay/tests/llm-replay.spec.ts`. ## Deferred - **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins bootHost's composed prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. - **CI browser provisioning**: reversal of the no-browser-in-CI ruling, staged criteria above (`TODO(ci-browser)`). - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. +- **Web error surface**: the client consumes no `agent/error` frames and a pre-chunk failure freezes no partial, so a non-retryable provider failure renders no error copy — the user sees the send simply stop. The AUTH scenario pins the current contract (no crash, composer recovers, turn logged `error`) and `FIXME(web-error-surface)` marks where visible error text gets asserted once the UI grows an error rendering. +- **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one. ## Consequences diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 9f806c7030..3e725b92cd 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -10,7 +10,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## 决策 -`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放,断言规范化后的会话区 aria 预期输出加进程内世界状态。不新增包(package);产品侧增量只有 `dsh-llm-replay` 的两处增量接口(`paceMs`、`ReplayHandle`)。 +`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放,断言规范化后的会话区 aria 预期输出加进程内世界状态。不新增包(package);产品侧增量为 `dsh-llm-replay` 的增量接口(`paceMs`、`ReplayHandle`,以及 `{ patches }` 覆写形式:对派生脚本按索引增补,使一份 sidecar 无需复制已录分片即可表达「第 N 次调用抛错/挂起,其余照录回放」),一处由重试场景暴露的 `dsh-llm` 修复(携带的 `failure` 快照对任何 Error 都生效——此前的 `instanceof` 判定会在两份包副本并存时丢弃提供方错误码,即源码平面回放叠在 lib 平面 boot 之上的情形),以及 web 组合此前缺失的 `llm-retry` 行。 ### Scaffold:`apps/web/tests/scaffold.ts` @@ -38,12 +38,15 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 模式与 fixture -`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}` token 化;随后一次无密钥 refresh 重新生成 `ui.expected.md`。两个场景的 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。 +`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}` token 化;随后一次无密钥 refresh 重新生成 `ui.expected.md`。每个发起提示的场景,其 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。 ### 场景 1. **`replay-round-trip`**——新会话,经真实输入框发送提示词,回放流式输出推理(reasoning)+ 一次在临时工作区真实执行的 `bash` 工具调用 + 最终文本(15ms 节奏)。断言安定后的 markdown、aria 预期输出与内联世界状态(bash `tool/call`、完成的 `turn/end`、>10 个分片事件)。 2. **`seeded-history`**——冷播种一份已录会话;侧栏列出它(分组行 → 会话行,默认折叠),打开后纯凭日志经 `session.history` 内的隐式冷恢复挂载渲染工具卡片与文本——replay 下零模型调用,因此没有任何绑定约束;record 模式实时驱动同一轮(真实 `read` 工具读取播种的工作区文件)来产出种子。 +3. **`live-interactions`**——一段不含工具调用的已录轮次经覆写 sidecar 承载三个仅回放的场景:sidecar 的内容本身写在 spec 里,每次运行时在 spec 自有的临时目录中生成文件(重试追加所用的派生成功条目经 `deriveReplayScript` 从 fixture 重新派生,绝不复制进已提交的 sidecar)。取消:一个带 `readyFile` 标记的 `{ patches }` `hang`——标记文件的存在证明流在测试点击 Stop 之前已停驻在轮次中途,使流中取消按构造即确定(`turn/end` 原因为 `aborted`,输入框重新启用)。AUTH 错误:一次落在 llm-retry 可重试集合之外的分片前 `throw`(`turn/end` 原因为 `error`,零条 `llm/retry` 事件,输入框恢复可用)。SERVER 重试:第 0 次调用 `throw` + 在第 1 次调用处追加 fixture 自身的成功条目,凭持久的 `llm/retry` 记录在浏览器中端到端证明 llm-retry(`request/header` 仅在变化时记录,因此尝试次数在那里不可见)。 +4. **`question-composer`**——已交付组合中常驻的 `ask_user_question` 接管:一段已录轮次在真实的 userInteraction seam 上阻塞于步骤中途,提问输入框(`[data-question-key]`)在浏览器中渲染,测试经它作答(这是驱动步骤对模型内容作出反应的唯一获准之处:没有这个回答,轮次无法完成,record 与 replay 皆然),工具结果携带所选的 label。预期输出:提问输入框稳定的等待态。 +5. **`steering`**——在提问输入框阻塞该步骤时做轮次中途 steering(中途引导),此即确定性的轮次中途窗口,不依赖任何时序。输入框在运行期间锁定,因此这一 steer 由页面经客户端所用的同一条同源 `/api` wire POST `session.prompt` `mode:'steer'`(`TODO(web-steer-composer)`:待有输入框手势后改为驱动它);下游的一切都是产品路径——gateway → `Agent.steer` → 步骤边界排空 → 持久的 `steering/message` → SSE → 带徽标的插话气泡。record 模式的 fixture 诚实性:除非真实模型的最终回复遵循了一条只有 steering 消息才携带的指令,否则该次录制被拒绝。 ### CI 立场 @@ -77,13 +80,15 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## Testing -车道自身:`pnpm run test:web` 与既有冒烟对一起无密钥运行两个场景;`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型重录某场景的 fixture;`DSH_SNAPSHOT=refresh` 无密钥重写两份 aria 预期输出。`paceMs` 校验、节奏下限、节奏中中止、`assertConsumed` 的两种失败形态钉在 `packages/support/llm-replay/tests/llm-replay.spec.ts`。 +车道自身:`pnpm run test:web` 与既有冒烟对一起无密钥运行所有场景;`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型重录某场景的 fixture;`DSH_SNAPSHOT=refresh` 无密钥重写各份 aria 预期输出。`paceMs` 校验、节奏下限、节奏中中止、`assertConsumed` 的两种失败形态,以及 `{ patches }` 的接受/拒绝路径(按索引换入保留邻项、`at == length` 追加、越界/非整数大声失败)钉在 `packages/support/llm-replay/tests/llm-replay.spec.ts`。 ## 暂缓 - **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 bootHost 组装的提示词/工具 schema(`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 - **CI 浏览器供给**:推翻 CI 无浏览器裁定,分阶段标准见上(`TODO(ci-browser)`)。 - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 +- **Web 错误表面**:客户端不消费任何 `agent/error` 帧,分片前的失败也没有可冻结的部分输出,因此不可重试的提供方失败不渲染任何错误文案——用户看到的只是发送就此停住。AUTH 场景钉住当前契约(不崩溃、输入框恢复可用、轮次记录为 `error`),`FIXME(web-error-surface)` 标记了待 UI 长出错误渲染后断言可见错误文本的位置。 +- **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer;`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 ## 后果 From 698b391bd6a64548364bcde3af5452d3b00ee747 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:13:39 +0800 Subject: [PATCH 07/19] refactor(tasks): split the task registry into seam and local implementation The tasks/ family now matches the capability-seam shape: @deepseek-ai/dsh-tasks keeps the abstract TaskService (ctx.tasks contract, vocabulary types, snapshot invariant companion) and the new @deepseek-ai/dsh-tasks-local carries the process-local registry (LocalTaskService: in-memory store, settlement, owner-cleanup effects, teardown, TASK_WAIT_TIMEOUT). Compositions and test harnesses now load dsh-tasks-local; producers, TaskKindMap merges, and dsh-tool-tasks keep importing the seam only. Producer misconfiguration diagnostics name dsh-tasks-local because loading the implementation is the fix. The registry behavior suite moves to tasks-local; the seam keeps a stub-subclass registration test and the probe-based invariant suite. --- ...06-20-generic-long-running-tool-runtime.md | 4 +- ...20-generic-long-running-tool-runtime.zh.md | 4 +- .../2026-07-26-task-registry-seam.md | 35 ++ .../2026-07-26-task-registry-seam.zh.md | 35 ++ apps/cli/cordis.yml | 2 +- apps/cli/package.json | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 3 +- docs/cordis-catalog/services.md | 36 +- docs/core-data-structures/tasks.md | 2 +- docs/module-graph.md | 13 +- .../headless-agent/tests/code-mode.e2e.ts | 4 +- examples/package.json | 1 + packages/bash/tool-bash/README.md | 2 +- packages/bash/tool-bash/package.json | 1 + packages/bash/tool-bash/src/index.ts | 4 +- .../bash/tool-bash/tests/integration.spec.ts | 6 +- packages/bash/tool-bash/tests/tools.spec.ts | 16 +- .../cordis/tool-cordis/src/api-catalog.ts | 20 +- packages/examples/agent-spine-demo/README.md | 2 +- .../examples/agent-spine-demo/package.json | 3 +- .../examples/agent-spine-demo/src/index.ts | 4 +- .../examples/agent-spine-demo/tsconfig.json | 3 + packages/pty/tool-pty/package.json | 1 + packages/pty/tool-pty/src/index.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 4 +- packages/subagent/tool-subagent/package.json | 1 + packages/subagent/tool-subagent/src/index.ts | 2 +- .../tool-subagent/tests/tool-subagent.spec.ts | 8 +- packages/tasks/README.md | 5 +- packages/tasks/tasks-local/README.md | 24 ++ packages/tasks/tasks-local/package.json | 45 +++ packages/tasks/tasks-local/src/index.ts | 365 +++++++++++++++++ packages/tasks/tasks-local/src/invariant.ts | 30 ++ .../tests/tasks.spec.ts | 33 +- packages/tasks/tasks-local/tsconfig.json | 30 ++ packages/tasks/tasks/README.md | 16 +- packages/tasks/tasks/package.json | 2 - packages/tasks/tasks/src/index.ts | 380 ++---------------- packages/tasks/tasks/tests/service.spec.ts | 82 ++++ packages/tasks/tasks/tsconfig.json | 3 - packages/tasks/tool-tasks/package.json | 1 + .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 9 +- pnpm-lock.yaml | 48 ++- python/sdk-runtime/package.json | 1 + scripts/gen-doc-graphs.ts | 5 +- scripts/gen-tool-catalog.ts | 4 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 49 files changed, 851 insertions(+), 458 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md create mode 100644 .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md create mode 100644 packages/tasks/tasks-local/README.md create mode 100644 packages/tasks/tasks-local/package.json create mode 100644 packages/tasks/tasks-local/src/index.ts create mode 100644 packages/tasks/tasks-local/src/invariant.ts rename packages/tasks/{tasks => tasks-local}/tests/tasks.spec.ts (97%) create mode 100644 packages/tasks/tasks-local/tsconfig.json create mode 100644 packages/tasks/tasks/tests/service.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 0b901fcf92..313d687b49 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -19,7 +19,7 @@ The `tasks/` package group owns background-task semantics: Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into incremental output and process cancellation; `dsh-tool-subagent` adapts a child run into final output and child disposal. The execution seams remain independent of sessions and the task registry. -`TaskService` is a concrete, process-local service. TODO(task-service-backend): separate its public contract from the implementation when a second backend defines the required lifecycle; a systemd-backed runtime is one plausible driver, but this PR does not speculate about its durability, reconnect, ownership, or observation semantics. +`TaskService` is the abstract seam in `@deepseek-ai/dsh-tasks`; the process-local registry is `LocalTaskService` in `@deepseek-ai/dsh-tasks-local` (the [task-registry seam Agent Note](2026-07-26-task-registry-seam.md) records that split). ## Runtime contract @@ -103,7 +103,7 @@ Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup, ### An immediate abstract task-runtime backend -The current `TaskStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so extracting an interface before a second implementation exists would freeze the wrong boundary. +The current `TaskStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry seam Agent Note](2026-07-26-task-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics. ### Consumer-owned authorization or cleanup events diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index e2860e3a91..39900e24ba 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -19,7 +19,7 @@ Status: implemented 长时间运行工具是生产方。`dsh-tool-bash` 将 `BashProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。执行 seam 保持独立,不依赖会话或任务注册表。 -`TaskService` 是一个具体的进程内服务。TODO(task-service-backend):当第二个后端明确所需生命周期后,将其公共契约与实现分离;systemd 驱动的运行时是一种可能方案,但本 PR(Pull Request)不臆测其持久性、重连、所有权或观察语义。 +`TaskService` 是 `@deepseek-ai/dsh-tasks` 中的抽象 seam;进程内注册表是 `@deepseek-ai/dsh-tasks-local` 中的 `LocalTaskService`(该拆分记录在[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.zh.md)中)。 ## 运行时契约 @@ -103,7 +103,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas ### 立即抽象任务运行时后端 -当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在第二种实现出现前抽取接口,会固化错误的边界。 +当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.zh.md)后来在不改变这些进程内语义的前提下,将契约与进程内实现分离。 ### 由消费方负责授权或清理事件 diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md new file mode 100644 index 0000000000..b785eb75a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md @@ -0,0 +1,35 @@ +# Agent Note: The task registry is a capability seam (`dsh-tasks` / `dsh-tasks-local`) + +Status: implemented + +English | [中文](2026-07-26-task-registry-seam.zh.md) + +## Problem + +The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) shipped `TaskService` as one concrete package: `@deepseek-ai/dsh-tasks` owned both the `ctx.tasks` contract every producer and control surface programs against and the process-local implementation (the in-memory store, settlement bookkeeping, owner-cleanup effects, teardown). That bundling recouples the two rates of change the repository's [capability-seam rule](2026-06-13-capability-seams.md) separates: swapping the registry's storage or lifecycle backend would churn the same package whose types and `ctx.tasks` surface producers (`dsh-tool-bash`, `dsh-tool-pty`, `dsh-tool-subagent`), the control surface (`dsh-tool-tasks`), and `TaskKindMap` extenders import. Every other swappable capability in the harness — bash, pty, fs, skill, subagent, web, session persistence — already carries the interface / implementation / consumer split; the task registry was the remaining `core`-mode exception, guarded only by a `TODO(task-service-backend)` comment. + +## Decision + +`tasks/` is now a three-package capability family in the bash-trio shape: + +- **`@deepseek-ai/dsh-tasks` (interface)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachSurface`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every implementation owes: registrations outlive producer and surface fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no control surface is attached. +- **`@deepseek-ai/dsh-tasks-local` (implementation)** — `LocalTaskService`, the process-local registry moved verbatim: the in-memory store, per-kind counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, and force-fail teardown. The `dsh-timeout` dependency moves here with it; the seam has no implementation dependencies. +- **`@deepseek-ai/dsh-tool-tasks` (consumer)** — unchanged; it injects `'tasks'` and never imports implementation types. + +Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks-local` because a deployment fixes them by loading the implementation, not the interface. Producers, `TaskKindMap` declaration merges, and the control surface keep importing `@deepseek-ai/dsh-tasks` only. + +The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` still passes callbacks and exact `Agent` objects, so a durable or cross-process backend still has design work to do before it can implement this interface (identity, restart, ownership, observation). The split moves that future work out of every consumer's dependency graph; it does not pre-design the backend. + +## Alternatives considered + +**Keep the concrete service until a second backend exists (status quo).** This was the original runtime note's position: extracting an interface before a second implementation risks freezing the wrong boundary. It lost because the boundary is no longer speculative — the eight service methods and their semantics have been stable across every producer integration since introduction, they are exactly the surface `dsh-tool-tasks` and the producers already program against, and the repository convention treats swappable capabilities as three packages by default. The residual risk (a durable backend needing contract changes) is unchanged by the split: those changes would land in the seam package either way, and today they would also churn every consumer's implementation dependency. + +**Interface-only extraction inside one package (export an abstract class beside the concrete one).** Rejected because it separates nothing operationally: consumers still depend on the package that carries the implementation and its dependencies, and a replacement backend still cannot ship without the local one in its graph. The package boundary is the unit of independent evolution here. + +**Splitting `types.ts` out but leaving the service concrete.** Rejected for the same reason — the types are not the seam; `ctx.tasks` and its method contract are. Producers need the service key and semantics, not just the shapes. + +## Consequences + +Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling package implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The seam README states the contract; the implementation README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the seam keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite. + +Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package — a boot that loads only `@deepseek-ai/dsh-tasks` gets a pending `ctx.tasks` and producers fail with the standard missing-service behavior rather than a bespoke message. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default. diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md new file mode 100644 index 0000000000..aa4df43b82 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 任务注册表是一个能力 seam(`dsh-tasks` / `dsh-tasks-local`) + +Status: implemented + +[English](2026-07-26-task-registry-seam.md) | 中文 + +## 问题 + +[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有所有生产方和控制接口面向编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除逻辑)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力(bash、pty、fs、skill、subagent、web、会话持久化)都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。 + +## 决策 + +`tasks/` 如今是一个 bash 三件套形态的三包能力家族: + +- **`@deepseek-ai/dsh-tasks`(接口)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的契约(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个实现都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且在没有附加任何控制接口时 `start` 拒绝启动工作。 +- **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除逻辑。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。 +- **`@deepseek-ai/dsh-tool-tasks`(消费方)**——保持不变;它注入 `'tasks'`,从不导入实现类型。 + +各组合配置在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`(CLI 的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness、工具目录生成器的启动流程)。生产方的配置错误诊断信息("background tasks unavailable: load …")点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 + +该 seam 保持进程内契约语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能实现此接口之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个消费方的依赖图;它并不预先设计后端。 + +## 曾考虑的替代方案 + +**在第二个后端出现之前保持具体服务(维持现状)。**这正是当初运行时 Agent Note 的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经在面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里,而若维持合并包的现状,它们还会连带搅动每个消费方的实现依赖。 + +**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决:它在运作层面并未分离任何东西。消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。 + +**拆出 `types.ts` 但让服务保持具体。**基于同样的理由否决:类型并不是 seam,`ctx.tasks` 及其方法契约才是。生产方需要的是服务键和语义,而不只是类型形状。 + +## 后果 + +换来的是:任务注册表如今与全仓库统一的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的后端落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;实现包的 README 拥有生命周期簿记的相关事实。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个基于桩子类的测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 + +代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合配置必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,得到的将是挂起的 `ctx.tasks`,生产方将按标准的服务缺失行为失败,而不会得到一条专门定制的消息。若推荐的默认后端日后换成其他实现,点名 `dsh-tasks-local` 的配置错误诊断信息会随之陈旧;这一代价已被接受。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b89df77c46..1d5c37cff5 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -52,7 +52,7 @@ name: '@deepseek-ai/dsh-agent' - id: tasks - name: '@deepseek-ai/dsh-tasks' + name: '@deepseek-ai/dsh-tasks-local' - id: agent-loop name: '@deepseek-ai/dsh-agent-loop' diff --git a/apps/cli/package.json b/apps/cli/package.json index 8799669e8d..4f3bb5be35 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -57,7 +57,7 @@ "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 0f2dfd1610..2da9652c59 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -117,6 +117,7 @@ flowchart LR 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"] @@ -192,6 +193,7 @@ flowchart LR pkg_subagent_spawn --> svc_subagents 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 @@ -326,7 +328,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 86331cf22a..d794425e10 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2058,7 +2058,7 @@ 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-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)) @@ -2077,6 +2077,7 @@ 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-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) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4d310a6690..e14d28564a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1607,9 +1607,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 /** @@ -1620,7 +1627,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 @@ -1628,7 +1635,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 @@ -1637,7 +1644,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. @@ -1647,7 +1654,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 @@ -1658,21 +1665,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; @@ -1681,7 +1687,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} @@ -1689,12 +1695,12 @@ 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.tokenMeter` — `TokenMeterService` diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index 2c7555b84d..8d7050be1b 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -149,4 +149,4 @@ interface TaskRead { ## Service behavior -[`TaskService`](../../packages/tasks/tasks/src/index.ts) provides atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the package contract and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface. +The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam defines atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local implementation. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the seam contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface. diff --git a/docs/module-graph.md b/docs/module-graph.md index abadead03f..87e0f7c0d5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -207,6 +207,7 @@ flowchart TD end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] + pkg_tasks_local["tasks-local"] pkg_tool_tasks["tool-tasks"] end subgraph group_workflow["packages/workflow"] @@ -433,7 +434,6 @@ flowchart TD pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants pkg_tasks --> pkg_session - pkg_tasks --> pkg_timeout pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_invariants @@ -508,6 +508,10 @@ flowchart TD pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session + pkg_tasks_local --> pkg_agent + pkg_tasks_local --> pkg_invariants + pkg_tasks_local --> pkg_tasks + pkg_tasks_local --> pkg_timeout pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -727,7 +731,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_skill pkg_agent_spine_demo --> pkg_skill_local pkg_agent_spine_demo --> pkg_system_prompt - pkg_agent_spine_demo --> pkg_tasks + pkg_agent_spine_demo --> pkg_tasks_local pkg_agent_spine_demo --> pkg_tool_bash pkg_agent_spine_demo --> pkg_tool_goal pkg_agent_spine_demo --> pkg_tool_skill @@ -882,7 +886,7 @@ flowchart TD | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | -| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | @@ -897,6 +901,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) | +| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | @@ -928,7 +933,7 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 86c1559b83..bb8fdfe260 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -19,7 +19,7 @@ import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context' -import TaskService from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -112,7 +112,7 @@ async function typedCodeModeHarness(): Promise { /** Keyless real-worker harness with the task-owned bash lifecycle. */ async function backgroundCodeModeHarness(cwd: string): Promise { const harness = await typedCodeModeHarness() - await harness.plugin(TaskService) + await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) diff --git a/examples/package.json b/examples/package.json index 395c135a2d..8a81d399b8 100644 --- a/examples/package.json +++ b/examples/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-subagent-acp": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index e58145ee67..0f957e7d89 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -139,7 +139,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. +Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. #### Token effect diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 6fe653fe6f..a89e147e0e 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -60,6 +60,7 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 81770b1595..b805c7fade 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -533,9 +533,9 @@ export function apply(ctx: Context, config: Config = {}): void { } const tasks = ctx.get('tasks') if (tasks === undefined) { - throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks') } - // The caller owns cancellation until TaskService commits detached ownership. + // The caller owns cancellation until ctx.tasks commits detached ownership. if (exec.signal.aborted) { const error = new HarnessError('tool call aborted', TOOL_ABORTED) error.name = 'AbortError' diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 8adf2165e0..e1315c232a 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -8,7 +8,7 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import TaskService from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -27,7 +27,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot, compression: 'none' }) } await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) @@ -169,7 +169,7 @@ describe('bash tool through the agent loop', () => { }) it('background: start ack → completion notice as user/message → task_output collects it', async () => { - // The task id is deterministic (a fresh TaskService counts per kind from 1), + // The task id is deterministic (a fresh LocalTaskService counts per kind from 1), // so the script can name `bash-1` without threading a generated id. const adapter = new MockAdapter([ toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }), diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 8811da6ca0..c2b0c3d31b 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -12,7 +12,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import TaskService from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' @@ -44,7 +44,7 @@ async function setupWithTasks() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) ;(ctx.bash as LocalBashExecutor).internals = { spillDir } @@ -180,7 +180,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) @@ -472,10 +472,10 @@ describe('background execution through the task runtime', () => { }) it('fails loud when the task runtime is not loaded', async () => { - const ctx = await setup() // no TaskService / ToolTasks + const ctx = await setup() // no LocalTaskService / ToolTasks const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks') }) it('a pre-aborted call is skipped before the process starts', async () => { @@ -483,7 +483,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(CountingStartExecutor) await ctx.plugin(ToolBash) @@ -511,7 +511,7 @@ describe('background execution through the task runtime', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(CountingStartExecutor) await ctx.plugin(ToolBash) @@ -1073,7 +1073,7 @@ describe('the model-facing bash tool builds its request from named args only (no await ctx.plugin(SessionStore) await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') }) } - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) await ctx.plugin(RecordingBashExecutor) await ctx.plugin(ToolBash, { dshHome: recordingDshHome }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d834d3ee21..3681efa015 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -768,38 +768,38 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'tasks', - summary: 'The `tasks` service: the runtime-global background task registry.', + summary: 'Abstract background task registry.', methods: [ { - signature: 'start(spec: TaskStart): TaskId', + signature: 'abstract start(spec: TaskStart): TaskId', jsDoc: '/**\n * Preflight access, validation, and owner cleanup before starting and\n * atomically registering work. A throwing starter leaves nothing registered;\n * after it returns, registration cannot fail. Settlement records the outcome,\n * notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `-N` id.\n */', }, { - signature: 'list(caller?: Agent): TaskSnapshot[]', + signature: 'abstract list(caller?: Agent): TaskSnapshot[]', jsDoc: '/**\n * List caller-owned and unowned tasks in registration order without exposing\n * another session\'s labels.\n * @param caller - reading agent; a non-agent caller sees only unowned tasks.\n * @returns fresh snapshots.\n */', }, { - signature: 'get(id: TaskId, caller?: Agent): TaskSnapshot', + signature: 'abstract get(id: TaskId, caller?: Agent): TaskSnapshot', jsDoc: '/**\n * Return a non-consuming snapshot without changing its read cursor or notice\n * state. Throws for an unknown or foreign task.\n * @param id - task to look up.\n * @param caller - reading agent checked against the owner.\n * @returns a fresh snapshot.\n */', }, { - signature: 'read(id: TaskId, caller?: Agent): TaskRead', + signature: 'abstract read(id: TaskId, caller?: Agent): TaskRead', jsDoc: '/**\n * Read the next stream delta, or the idempotent final output after settlement.\n * A terminal read marks the task reported. Throws for an unknown or foreign\n * task.\n * @param id - task to read.\n * @param caller - reading agent checked against the owner.\n * @returns output text and the post-read snapshot.\n */', }, { - signature: 'kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'', + signature: 'abstract kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'', jsDoc: '/**\n * Request cancellation, then mark the task stopping and reported. A producer\n * throw propagates without changing task state. Throws for an unknown or\n * foreign task.\n * @param id - task to cancel.\n * @param caller - killing agent checked against the owner.\n * @param reason - logged reason forwarded to the producer.\n * @returns `requested` for live work, otherwise `already-finished`.\n */', }, { - signature: 'async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise', - jsDoc: '/**\n * Wait for settlement or timeout without cancelling the task. Caller abort\n * rejects only while the task is live; after settlement it returns the\n * terminal snapshot so a notice suppressed for this waiter is still delivered.\n * Timed-out and aborted waits detach their resolvers. Throws for invalid,\n * unknown, or foreign input.\n * @param id - task to wait for.\n * @param timeoutMs - positive finite wait bound in milliseconds.\n * @param caller - waiting agent checked against the owner.\n * @param signal - optional cancellation of the wait itself.\n * @returns snapshot at settlement or timeout.\n */', + signature: 'abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Wait for settlement or timeout without cancelling the task. Caller abort\n * rejects only while the task is live; after settlement the terminal\n * snapshot wins so a notice suppressed for this waiter is still delivered.\n * Throws for invalid, unknown, or foreign input.\n * @param id - task to wait for.\n * @param timeoutMs - positive finite wait bound in milliseconds.\n * @param caller - waiting agent checked against the owner.\n * @param signal - optional cancellation of the wait itself.\n * @returns snapshot at settlement or timeout.\n */', }, { - signature: 'onTaskDone(listener: TaskDoneListener): () => void', + signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void', jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', }, { - signature: 'attachSurface(name: string): () => void', + signature: 'abstract attachSurface(name: string): () => void', jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', }, ], diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 05ea5c75e2..bfcfef446d 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -22,7 +22,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-tool-goal optional model-facing goal controls @deepseek-ai/dsh-goal-session optional same-session goal-round driver @deepseek-ai/dsh-llm-retry bounded transient request retry policy -@deepseek-ai/dsh-tasks generic background-task registry +@deepseek-ai/dsh-tasks-local generic background-task registry @deepseek-ai/dsh-invariants configurable invariant registry service @deepseek-ai/dsh-session/invariant @deepseek-ai/dsh-agent/invariant diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index bf69e27787..923a9aace6 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -42,7 +42,7 @@ "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-skill-local": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", + "@deepseek-ai/dsh-tasks-local": "^0.0.1", "@deepseek-ai/dsh-tool-bash": "^0.0.1", "@deepseek-ai/dsh-tool-goal": "^0.0.1", "@deepseek-ai/dsh-tool-skill": "^0.0.1", @@ -74,6 +74,7 @@ "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index c43ee2ab8d..0ac96aaa85 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -22,7 +22,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal' import * as goalSession from '@deepseek-ai/dsh-goal-session' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' -import TaskService from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants' import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant' @@ -223,7 +223,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(toolGoal, config.goals.tool ?? {}) ctx.plugin(goalSession) } - ctx.plugin(TaskService) + ctx.plugin(LocalTaskService) ctx.plugin(InvariantService, config.invariants ?? {}) ctx.plugin(sessionInvariant) ctx.plugin(agentInvariant) diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 0888da5d24..670cd9a629 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -74,6 +74,9 @@ { "path": "../../tasks/tasks" }, + { + "path": "../../tasks/tasks-local" + }, { "path": "../../tasks/tool-tasks" } diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index 2d36fb5c9b..d8b2564736 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts index fc66d2646e..abd0664893 100644 --- a/packages/pty/tool-pty/src/index.ts +++ b/packages/pty/tool-pty/src/index.ts @@ -250,7 +250,7 @@ export function apply(ctx: Context, config: Config = {}): void { if (args.run_in_background === true) { if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration') const tasks = ctx.get('tasks') - if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks') let cancelRequested = false const taskId = tasks.start({ kind: 'pty-send', diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index e0b854ee43..dbc05605c6 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -9,7 +9,7 @@ import ToolRegistry, { renderToolsSdk } from '@deepseek-ai/dsh-tools' import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty' import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty' -import TaskService from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as ToolPty from '@deepseek-ai/dsh-tool-pty' @@ -106,7 +106,7 @@ async function setupBase(tasks: boolean) { const stub = stubBackend() ctx.pty.registerBackend(stub.backend) if (tasks) { - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) } return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') } diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 6ed447dd56..b5c7b5d94f 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 4eb29d0c6e..cd2eb590ae 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -323,7 +323,7 @@ export function apply(ctx: Context, config: Config): void { } const tasks = ctx.get('tasks') if (tasks === undefined) { - throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks') } // Task preflight finishes before the starter can spawn a child. const id = tasks.start({ diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 8468216d49..d3409e4604 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -8,7 +8,7 @@ import { type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import TaskService from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' @@ -641,7 +641,7 @@ describe('dsh-tool-subagent background mode', () => { async function backgroundSetup(toolConfig: tool.Config, mockConfig: Partial = {}) { const ctx = await setup(toolConfig, mockConfig) await ctx.plugin(AgentRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) return ctx } @@ -680,7 +680,7 @@ describe('dsh-tool-subagent background mode', () => { const ctx = await setup({ provider: 'mock' }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks') + expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks-local') }) it('skips background startup when the tool signal is already aborted', async () => { @@ -868,7 +868,7 @@ describe('background preflight failure (no orphaned child, by construction)', () // With no control surface, task preflight fails before the provider can spawn. const ctx = await setup({ provider: 'mock' }) await ctx.plugin(AgentRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) const scopeFiber = ctx.plugin(() => {}) const id = SessionId('sess-p') const parent = { diff --git a/packages/tasks/README.md b/packages/tasks/README.md index 71c68ea250..693a25d38d 100644 --- a/packages/tasks/README.md +++ b/packages/tasks/README.md @@ -1,10 +1,11 @@ # tasks/ — background task capability family -The shared home for background-task ids, owner isolation, reads, cancellation, waiting, and completion notices. Bash, subagents, and future long-running tools use one model-facing protocol. See the [background-task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md). +The shared home for background-task ids, owner isolation, reads, cancellation, waiting, and completion notices. Bash, subagents, and future long-running tools use one model-facing protocol. See the [background-task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and the [task-registry seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md). | Package | ctx key | Role | |---|---|---| -| [`tasks`](tasks/README.md) (`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | The registry service: branded `-N` ids, owner-fenced read/kill/wait/list, settlement bookkeeping, the awaited owner-cleanup path, and the `attachSurface` misconfiguration fence | +| [`tasks`](tasks/README.md) (`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | The registry seam: branded `-N` ids, the owner-fenced read/kill/wait/list contract, snapshot vocabulary, the `attachSurface` misconfiguration fence, and the snapshot invariant companion | +| [`tasks-local`](tasks-local/README.md) (`@deepseek-ai/dsh-tasks-local`) | — | The process-local registry implementation: in-memory records, first-wins settlement bookkeeping, and the awaited owner-cleanup and teardown paths | | [`tool-tasks`](tool-tasks/README.md) (`@deepseek-ai/dsh-tool-tasks`) | — | The model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection, and the background-habit prompt section | The registry owns state across producer or surface reloads; the tool package owns presentation. Producers register execution hooks through `ctx.tasks.start` and own whether their config exposes `run_in_background`. diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md new file mode 100644 index 0000000000..5f57d3409d --- /dev/null +++ b/packages/tasks/tasks-local/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-tasks-local + +Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry seam: `LocalTaskService` keeps every record in memory, issues per-kind `-N` ids, and hands out fresh snapshots, never live state. It has no config; load it as a plugin and it registers as `ctx.tasks`. + +## Lifecycle + +Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup. + +Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown. + +Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion surfaces do not duplicate notices. + +## Model Experience + +Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **Tasks are process-local** — records die with the harness process; durable or cross-restart execution needs a separate backend implementing the seam. +- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely. diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json new file mode 100644 index 0000000000..cdcc823826 --- /dev/null +++ b/packages/tasks/tasks-local/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-tasks-local", + "description": "Process-local implementation of the DeepSeek Harness background task registry seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts new file mode 100644 index 0000000000..f108022b17 --- /dev/null +++ b/packages/tasks/tasks-local/src/index.ts @@ -0,0 +1,365 @@ +/** + * Process-local implementation of the background task registry seam + * (`ctx.tasks`). It keeps every record in memory and hands out fresh + * snapshots, never live state. + * + * Registrations outlive producer and control-surface fibers. Agent or service + * disposal cancels live work and awaits compliant producers; a throwing + * teardown cancel force-fails only the record and reports a possible orphan. + * @module @deepseek-ai/dsh-tasks-local + */ + +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks' +import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks' + +/** Timeout code that distinguishes a bounded wait from caller cancellation. */ +export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' + +/** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */ +interface TrackedTask { + id: TaskId + kind: TaskKind + label: string + outputLimitBytes: number | undefined + /** Exact lifecycle owner; session-id authorization is derived from it. */ + owner: Agent | undefined + cancel: (reason?: string) => void + readOutput: (() => string) | undefined + status: TaskStatus + detail: string | undefined + output: string | undefined + startedAt: number + finishedAt: number | undefined + reported: boolean + /** Resolves once the terminal snapshot is recorded and listeners notified. */ + settled: Promise + /** Resolver for {@link settled}, called by the first effective settlement. */ + markSettled: () => void + /** Live waits; settlement with a waiter marks the task reported. */ + waiters: number + /** Removable resolvers for live waits; timeout/abort unregister before the task settles. */ + waitResolvers: Set<() => void> +} + +/** True for the three terminal {@link TaskStatus} values. */ +function isTerminal(status: TaskStatus): boolean { + return status === 'completed' || status === 'killed' || status === 'failed' +} + +/** + * The in-memory `tasks` registry. See the seam contract in + * `@deepseek-ai/dsh-tasks` for the ownership, isolation, and lifecycle + * semantics this implementation honors. + */ +export class LocalTaskService extends TaskService { + private store = new Map() + private counters = new Map() + private surfaces = new Set() + private listeners = new Set() + private listenersClosed = false + /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ + private ownerCleanups = new Map Promise | void>() + /** Service context used by detached settlement continuations and teardown. */ + private readonly selfCtx: Context + + constructor(ctx: Context) { + super(ctx) + this.selfCtx = ctx + ctx.effect(() => () => this.disposeAll(), 'tasks teardown') + } + + start(spec: TaskStart): TaskId { + if (this.surfaces.size === 0) { + throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') + } + if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string') + if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string') + if (spec.outputLimitBytes !== undefined + && (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) { + throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`) + } + if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) + + const hooks = spec.run() + const count = (this.counters.get(spec.kind) ?? 0) + 1 + this.counters.set(spec.kind, count) + const id = TaskId(`${spec.kind}-${count}`) + + let markSettled!: () => void + const settled = new Promise((resolve) => { markSettled = resolve }) + const task: TrackedTask = { + id, + kind: spec.kind, + label: spec.label, + outputLimitBytes: spec.outputLimitBytes, + owner: spec.owner, + cancel: hooks.cancel.bind(hooks), + readOutput: hooks.readOutput?.bind(hooks), + status: 'running', + detail: undefined, + output: undefined, + startedAt: Date.now(), + finishedAt: undefined, + reported: false, + settled, + markSettled, + waiters: 0, + waitResolvers: new Set(), + } + this.store.set(id, task) + + void hooks.done.then( + (outcome) => { this.settle(task, outcome) }, + (error: unknown) => { + // Contain a producer contract violation so cleanup and waiters cannot hang. + this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`) + this.settle(task, { status: 'failed', detail: String(error) }) + }, + ) + return id + } + + list(caller?: Agent): TaskSnapshot[] { + const session = caller?.id + return [...this.store.values()] + .filter(task => task.owner === undefined || task.owner.id === session) + .map(task => this.snapshot(task)) + } + + get(id: TaskId, caller?: Agent): TaskSnapshot { + const task = this.expect(id) + this.assertAccess(task, caller) + return this.snapshot(task) + } + + read(id: TaskId, caller?: Agent): TaskRead { + const task = this.expect(id) + this.assertAccess(task, caller) + const text = task.readOutput !== undefined + ? task.readOutput() + : isTerminal(task.status) ? task.output ?? '' : '' + if (isTerminal(task.status)) task.reported = true + return { text, snapshot: this.snapshot(task) } + } + + kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' { + const task = this.expect(id) + this.assertAccess(task, caller) + if (isTerminal(task.status)) { + task.reported = true + return 'already-finished' + } + // Cancel first so a throw leaves both lifecycle and notice state unchanged. + task.cancel(reason) + task.status = 'stopping' + task.reported = true + return 'requested' + } + + async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise { + const task = this.expect(id) + this.assertAccess(task, caller) + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`) + } + if (!isTerminal(task.status)) { + if (signal?.aborted) throw new Error('wait aborted') + // Abort removes the waiter synchronously so same-tick settlement cannot + // suppress a notice for a wait that will reject. + task.waiters += 1 + let counted = true + const uncount = (): void => { + if (!counted) return + counted = false + task.waiters -= 1 + } + try { + // The scoped deadline distinguishes a successful wait timeout from + // caller cancellation and clears its timer on every exit. + using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT) + await new Promise((resolve, reject) => { + const onSettled = (): void => { + task.waitResolvers.delete(onSettled) + d.signal.removeEventListener('abort', onAbort) + resolve() + } + const onAbort = (): void => { + task.waitResolvers.delete(onSettled) + if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) { + resolve() + } else if (isTerminal(task.status)) { + // Settlement suppressed the notice for this waiter; deliver it. + resolve() + } else { + uncount() + reject(new Error('wait aborted')) + } + } + task.waitResolvers.add(onSettled) + d.signal.addEventListener('abort', onAbort, { once: true }) + }) + } finally { + uncount() + } + } + if (isTerminal(task.status)) task.reported = true + return this.snapshot(task) + } + + onTaskDone(listener: TaskDoneListener): () => void { + const dispose = this.ctx.effect(() => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + }, 'tasks.onTaskDone()') + return () => void dispose() + } + + attachSurface(name: string): () => void { + // One token per call keeps duplicate labels independently disposable. + const token = Symbol(name) + const dispose = this.ctx.effect(() => { + this.surfaces.add(token) + return () => this.surfaces.delete(token) + }, 'tasks.attachSurface()') + return () => void dispose() + } + + /** Look up a task or fail loud. */ + private expect(id: TaskId): TrackedTask { + const task = this.store.get(id) + if (task === undefined) throw new Error(`unknown task ${id}`) + return task + } + + /** + * The isolation fence: a task with an owner is reachable only by callers + * whose session id matches (`!== undefined` semantics — an unowned task is + * open, and a no-agent caller can never match an owned one). + */ + private assertAccess(task: TrackedTask, caller?: Agent): void { + if (task.owner !== undefined && task.owner.id !== caller?.id) { + throw new Error(`task ${task.id} belongs to another session`) + } + } + + /** Project a fresh read-only snapshot from the mutable record. */ + private snapshot(task: TrackedTask): TaskSnapshot { + const ownerSession = task.owner?.id + return { + id: task.id, + kind: task.kind, + label: task.label, + ...task.outputLimitBytes !== undefined ? { outputLimitBytes: task.outputLimitBytes } : {}, + ...ownerSession !== undefined ? { ownerSession } : {}, + status: task.status, + ...task.detail !== undefined ? { detail: task.detail } : {}, + startedAt: task.startedAt, + ...task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {}, + reported: task.reported, + } + } + + /** + * Record the first terminal outcome, notify contained listeners, and release + * waiters. First-wins preserves a teardown force-failure against late producer + * settlement. Pending waits mark the task reported before listeners run. + */ + private settle(task: TrackedTask, outcome: TaskOutcome): void { + if (isTerminal(task.status)) return + task.status = outcome.status + task.detail = outcome.detail + task.output = outcome.output + task.finishedAt = Date.now() + if (task.waiters > 0) task.reported = true + if (!this.listenersClosed) { + const snapshot = this.snapshot(task) + for (const listener of this.listeners) { + try { + const returned = listener(snapshot, task.owner) + void Promise.resolve(returned).catch((error: unknown) => { + this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`) + }) + } catch (error: unknown) { + this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`) + } + } + } + const waitResolvers = [...task.waitResolvers] + task.waitResolvers.clear() + for (const resolveWait of waitResolvers) resolveWait() + task.markSettled() + } + + /** + * Attach one awaited cleanup through the exact owner's scope. This survives + * producer reloads and joins agent quiescence; the retained disposer lets + * service teardown detach the cross-fiber effect. Fails when the registry is + * absent or the owner is not its currently registered instance. + */ + private ensureOwnerCleanup(owner: Agent): void { + const ownerId = owner.id + const agents = this.selfCtx.get('agents') + if (agents === undefined) { + throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + } + if (agents.get(ownerId) !== owner) { + throw new Error(`agent "${ownerId}" is not the registered agent instance (background task owner must be live)`) + } + if (this.ownerCleanups.has(owner)) return + // Record only after attach succeeds; a disposing scope rejects new effects. + const detach = owner.ctx.effect(() => async () => { + this.ownerCleanups.delete(owner) + await this.disposeOwned(owner) + }, 'tasks.ownerCleanup()') + this.ownerCleanups.set(owner, detach) + } + + /** Cancel, await terminal records, and drop every task owned by one exact agent lifecycle. */ + private async disposeOwned(owner: Agent): Promise { + const owned = [...this.store.values()].filter(task => task.owner === owner) + this.cancelForTeardown(owned, 'owner disposed') + await Promise.all(owned.map(task => task.settled)) + for (const task of owned) this.store.delete(task.id) + } + + /** + * Close listeners, cancel live tasks, await settlement, and detach owner + * effects. Throwing cancels are force-failed to avoid teardown deadlock. + */ + private async disposeAll(): Promise { + this.listenersClosed = true + this.listeners.clear() + const all = [...this.store.values()] + this.cancelForTeardown(all, 'tasks service disposed') + await Promise.all(all.map(task => task.settled)) + this.store.clear() + // Detach cross-fiber owner effects after the shared store is quiescent. + const ownerCleanups = [...this.ownerCleanups.values()] + this.ownerCleanups.clear() + await Promise.all(ownerCleanups.map(cleanup => Promise.resolve(cleanup()))) + } + + /** + * Cancel tasks during teardown with per-task containment. A throwing cancel + * force-fails the record and reports a possible orphan; a cancel that returns + * without settling remains indistinguishable from a slow stop and may stall. + */ + private cancelForTeardown(tasks: TrackedTask[], reason: string): void { + for (const task of tasks) { + if (isTerminal(task.status)) continue + try { + task.cancel(reason) + task.status = 'stopping' + } catch (error: unknown) { + const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}` + this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`) + this.settle(task, { status: 'failed', detail }) + } + } + } +} + +export default LocalTaskService diff --git a/packages/tasks/tasks-local/src/invariant.ts b/packages/tasks/tasks-local/src/invariant.ts new file mode 100644 index 0000000000..3447287c08 --- /dev/null +++ b/packages/tasks/tasks-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tasks-local`. + * @module @deepseek-ai/dsh-tasks-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tasks-local' + +/** Cordis companion plugin name. */ +export const name = 'tasks-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the seam companion in `@deepseek-ai/dsh-tasks` already + * validates every registry snapshot this implementation publishes. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts similarity index 97% rename from packages/tasks/tasks/tests/tasks.spec.ts rename to packages/tasks/tasks-local/tests/tasks.spec.ts index 015f1c4f2b..d237dbe094 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -3,8 +3,9 @@ import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' +import { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { @@ -65,7 +66,7 @@ function producer(overrides: Partial & TaskHooks> = {}) { async function harness() { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) ctx.tasks.attachSurface('test-surface') return ctx } @@ -81,14 +82,14 @@ function waitResolverCount(ctx: Context, id: TaskId): number { return task.waitResolvers.size } -describe('TaskService.start', () => { +describe('LocalTaskService.start', () => { it('preserves the SessionId brand on public owner snapshots', () => { expectTypeOf().toEqualTypeOf() }) it('refuses to register while no control surface is attached', async () => { const ctx = new Context() - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) expect(() => ctx.tasks.start(producer().spec)) .toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') }) @@ -109,7 +110,7 @@ describe('TaskService.start', () => { }) }) -describe('TaskService reads and settlement', () => { +describe('LocalTaskService reads and settlement', () => { it('stream kinds read a consuming delta; terminal reads mark reported', async () => { const ctx = await harness() const chunks = ['first', '', 'rest'] @@ -229,7 +230,7 @@ describe('TaskService reads and settlement', () => { }) }) -describe('TaskService.kill', () => { +describe('LocalTaskService.kill', () => { it('cancels a live task with the forwarded reason and suppresses the notice', async () => { const ctx = await harness() const seen: TaskSnapshot[] = [] @@ -284,7 +285,7 @@ describe('TaskService.kill', () => { }) }) -describe('TaskService.wait', () => { +describe('LocalTaskService.wait', () => { it('resolves with the terminal snapshot when the task settles, marked reported', async () => { const ctx = await harness() const seen: TaskSnapshot[] = [] @@ -394,7 +395,7 @@ describe('TaskService.wait', () => { }) }) -describe('TaskService owner isolation', () => { +describe('LocalTaskService owner isolation', () => { it('fences read/kill/wait to the owning session and keeps unowned tasks open', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') @@ -433,7 +434,7 @@ describe('TaskService owner isolation', () => { it('rejects an owned registration when no agent registry is mounted', async () => { const ctx = new Context() - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) ctx.tasks.attachSurface('test-surface') expect(() => ctx.tasks.start(producer({ owner: stubAgent(ctx, 'a') }).spec)) .toThrow('background task ownership requires the agent registry') @@ -498,7 +499,7 @@ describe('TaskService owner isolation', () => { }) }) -describe('TaskService owner cleanup', () => { +describe('LocalTaskService owner cleanup', () => { it('drains the owner: cancels live tasks, awaits settlement, drops snapshots', async () => { const ctx = await harness() const owner = stubAgent(ctx, 'owner') @@ -580,7 +581,7 @@ describe('TaskService owner cleanup', () => { it('registers owner cleanup on the agent scope rather than the tasks fiber', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - const tasksFiber = await ctx.plugin(TaskService) + const tasksFiber = await ctx.plugin(LocalTaskService) ctx.tasks.attachSurface('test-surface') const owner = stubAgent(ctx, 'owner') ctx.agents.register(owner) @@ -646,11 +647,11 @@ describe('TaskService owner cleanup', () => { }) }) -describe('TaskService disposal', () => { +describe('LocalTaskService disposal', () => { it('cancels live tasks, awaits settlement, and silences listeners', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - const fiber = await ctx.plugin(TaskService) + const fiber = await ctx.plugin(LocalTaskService) const surface = await ctx.plugin(Object.assign((inner: Context) => { inner.tasks.attachSurface('test-surface') }, { inject: ['tasks'] })) @@ -678,7 +679,7 @@ describe('TaskService disposal', () => { it('force-fails a throwing cancel so service disposal does not await producer done', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - const fiber = await ctx.plugin(TaskService) + const fiber = await ctx.plugin(LocalTaskService) ctx.tasks.attachSurface('test-surface') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const seen: TaskSnapshot[] = [] @@ -716,7 +717,7 @@ describe('TaskService disposal', () => { it('detaches owner effects from still-live agent scopes when the service unloads', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - const tasksFiber = await ctx.plugin(TaskService) + const tasksFiber = await ctx.plugin(LocalTaskService) ctx.tasks.attachSurface('test-surface') const owner = stubAgent(ctx, 'owner') ctx.agents.register(owner) @@ -741,7 +742,7 @@ describe('TaskService disposal', () => { it('detaching the last surface re-arms the register fence', async () => { const ctx = new Context() - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) const detachA1 = ctx.tasks.attachSurface('a') const detachA2 = ctx.tasks.attachSurface('a') // duplicate name counts independently const fiber = await ctx.plugin(Object.assign((inner: Context) => { diff --git a/packages/tasks/tasks-local/tsconfig.json b/packages/tasks/tasks-local/tsconfig.json new file mode 100644 index 0000000000..147e3915bc --- /dev/null +++ b/packages/tasks/tasks-local/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../tasks" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 1d9ce2b249..f8808f6486 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-tasks -The process-local background task registry (`ctx.tasks`). It gives long-running producers shared ids, owner isolation, reads, cancellation, waiting, notices, and cleanup. Producer plugins extend `TaskKindMap` with their opaque id namespace. +The background task registry seam (`ctx.tasks`). The abstract `TaskService` and its vocabulary types give long-running producers shared ids, owner isolation, reads, cancellation, waiting, notices, and cleanup under one contract; the process-local registry lives in [`dsh-tasks-local`](../tasks-local/README.md). Producer plugins extend `TaskKindMap` with their opaque id namespace. -## Service API +## Service contract - `start(spec): TaskId` validates the control surface, spec, exact live owner, and optional positive `outputLimitBytes` before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step. - `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks. @@ -16,13 +16,9 @@ Owned access compares the task's `SessionId` with the caller's. Ids such as `bas `outputLimitBytes` is producer-owned model-presentation policy carried unchanged into snapshots. A control surface applies it after adding status or notice metadata; the registry does not rewrite producer output or invent a default for producers that omit it. -## Lifecycle +Implementations also owe the lifecycle semantics of the contract: registrations outlive producer and control-surface fibers, owner and service disposal cancel live work and await compliant producers, and settlement is first-wins — one terminal record, one round of contained listener notification, released waiters. -Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup. - -Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown. - -See the [task type catalog](../../../docs/core-data-structures/tasks.md) and [runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md). +See the [task type catalog](../../../docs/core-data-structures/tasks.md), the [runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md), and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md). ## Model Experience @@ -34,8 +30,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Tasks are process-local** — durable or cross-restart execution needs a separate lifecycle. -- **The service and implementation are not split** — a second backend must define the lifecycle that shapes that boundary. - **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API. - **Foreground work cannot be promoted** — producers choose foreground or background before starting. -- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely. +- **The contract is in-process** — `TaskStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 128a8d2c4e..9bc02879cf 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -31,7 +31,6 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { @@ -39,7 +38,6 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 16f0807656..17e617e8a7 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -1,19 +1,14 @@ /** - * The in-process background task registry (`ctx.tasks`). It owns task ids, - * session-scoped access, lifecycle state, completion listeners, and owner - * cleanup while producers retain their execution resources. - * - * Registrations outlive producer and control-surface fibers. Agent or service - * disposal cancels live work and awaits compliant producers; a throwing - * teardown cancel force-fails only the record and reports a possible orphan. + * The background task registry seam (`ctx.tasks`). It owns the contract for + * task ids, session-scoped access, lifecycle state, completion listeners, and + * owner cleanup while producers retain their execution resources. The + * process-local registry lives in `@deepseek-ai/dsh-tasks-local`. * @module @deepseek-ai/dsh-tasks */ import { Context, Service } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { TaskId } from './types.ts' -import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from './types.ts' +import type { TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart } from './types.ts' export { TaskId } from './types.ts' export type { @@ -34,61 +29,27 @@ declare module 'cordis' { } } -/** Timeout code that distinguishes a bounded wait from caller cancellation. */ -export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' - -/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */ -interface TrackedTask { - id: TaskId - kind: TaskKind - label: string - outputLimitBytes: number | undefined - /** Exact lifecycle owner; session-id authorization is derived from it. */ - owner: Agent | undefined - cancel: (reason?: string) => void - readOutput: (() => string) | undefined - status: TaskStatus - detail: string | undefined - output: string | undefined - startedAt: number - finishedAt: number | undefined - reported: boolean - /** Resolves once the terminal snapshot is recorded and listeners notified. */ - settled: Promise - /** Resolver for {@link settled}, called by the first effective settlement. */ - markSettled: () => void - /** Live waits; settlement with a waiter marks the task reported. */ - waiters: number - /** Removable resolvers for live waits; timeout/abort unregister before the task settles. */ - waitResolvers: Set<() => void> -} - -/** True for the three terminal {@link TaskStatus} values. */ -function isTerminal(status: TaskStatus): boolean { - return status === 'completed' || status === 'killed' || status === 'failed' -} - /** - * 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. + * - {@link start} refuses work while no control surface is attached, so a + * producer cannot start work that callers cannot collect or stop. */ -// TODO(task-service-backend): Separate the service contract from this -// process-local implementation when a second backend defines its lifecycle. -export class TaskService extends Service { - private store = new Map() - private counters = new Map() - private surfaces = new Set() - private listeners = new Set() - private listenersClosed = false - /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ - private ownerCleanups = new Map Promise | void>() - /** Service context used by detached settlement continuations and teardown. */ - private readonly selfCtx: Context - +export abstract class TaskService extends Service { constructor(ctx: Context) { super(ctx, 'tasks') - this.selfCtx = ctx - ctx.effect(() => () => this.disposeAll(), 'tasks teardown') } /** @@ -99,56 +60,7 @@ export class TaskService extends Service { * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ - start(spec: TaskStart): TaskId { - if (this.surfaces.size === 0) { - throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') - } - if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string') - if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string') - if (spec.outputLimitBytes !== undefined - && (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) { - throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`) - } - if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) - - const hooks = spec.run() - const count = (this.counters.get(spec.kind) ?? 0) + 1 - this.counters.set(spec.kind, count) - const id = TaskId(`${spec.kind}-${count}`) - - let markSettled!: () => void - const settled = new Promise((resolve) => { markSettled = resolve }) - const task: TrackedTask = { - id, - kind: spec.kind, - label: spec.label, - outputLimitBytes: spec.outputLimitBytes, - owner: spec.owner, - cancel: hooks.cancel.bind(hooks), - readOutput: hooks.readOutput?.bind(hooks), - status: 'running', - detail: undefined, - output: undefined, - startedAt: Date.now(), - finishedAt: undefined, - reported: false, - settled, - markSettled, - waiters: 0, - waitResolvers: new Set(), - } - this.store.set(id, task) - - void hooks.done.then( - (outcome) => { this.settle(task, outcome) }, - (error: unknown) => { - // Contain a producer contract violation so cleanup and waiters cannot hang. - this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`) - this.settle(task, { status: 'failed', detail: String(error) }) - }, - ) - return id - } + abstract start(spec: TaskStart): TaskId /** * List caller-owned and unowned tasks in registration order without exposing @@ -156,12 +68,7 @@ export class TaskService extends Service { * @param caller - reading agent; a non-agent caller sees only unowned tasks. * @returns fresh snapshots. */ - list(caller?: Agent): TaskSnapshot[] { - const session = caller?.id - return [...this.store.values()] - .filter(task => task.owner === undefined || task.owner.id === session) - .map(task => this.snapshot(task)) - } + abstract list(caller?: Agent): TaskSnapshot[] /** * Return a non-consuming snapshot without changing its read cursor or notice @@ -170,11 +77,7 @@ export class TaskService extends Service { * @param caller - reading agent checked against the owner. * @returns a fresh snapshot. */ - get(id: TaskId, caller?: Agent): TaskSnapshot { - const task = this.expect(id) - this.assertAccess(task, caller) - return this.snapshot(task) - } + abstract get(id: TaskId, caller?: Agent): TaskSnapshot /** * Read the next stream delta, or the idempotent final output after settlement. @@ -184,15 +87,7 @@ export class TaskService extends Service { * @param caller - reading agent checked against the owner. * @returns output text and the post-read snapshot. */ - read(id: TaskId, caller?: Agent): TaskRead { - const task = this.expect(id) - this.assertAccess(task, caller) - const text = task.readOutput !== undefined - ? task.readOutput() - : isTerminal(task.status) ? task.output ?? '' : '' - if (isTerminal(task.status)) task.reported = true - return { text, snapshot: this.snapshot(task) } - } + abstract read(id: TaskId, caller?: Agent): TaskRead /** * Request cancellation, then mark the task stopping and reported. A producer @@ -203,81 +98,20 @@ export class TaskService extends Service { * @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' { - const task = this.expect(id) - this.assertAccess(task, caller) - if (isTerminal(task.status)) { - task.reported = true - return 'already-finished' - } - // Cancel first so a throw leaves both lifecycle and notice state unchanged. - task.cancel(reason) - task.status = 'stopping' - task.reported = true - return 'requested' - } + 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 { - const task = this.expect(id) - this.assertAccess(task, caller) - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`) - } - if (!isTerminal(task.status)) { - if (signal?.aborted) throw new Error('wait aborted') - // Abort removes the waiter synchronously so same-tick settlement cannot - // suppress a notice for a wait that will reject. - task.waiters += 1 - let counted = true - const uncount = (): void => { - if (!counted) return - counted = false - task.waiters -= 1 - } - try { - // The scoped deadline distinguishes a successful wait timeout from - // caller cancellation and clears its timer on every exit. - using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT) - await new Promise((resolve, reject) => { - const onSettled = (): void => { - task.waitResolvers.delete(onSettled) - d.signal.removeEventListener('abort', onAbort) - resolve() - } - const onAbort = (): void => { - task.waitResolvers.delete(onSettled) - if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) { - resolve() - } else if (isTerminal(task.status)) { - // Settlement suppressed the notice for this waiter; deliver it. - resolve() - } else { - uncount() - reject(new Error('wait aborted')) - } - } - task.waitResolvers.add(onSettled) - d.signal.addEventListener('abort', onAbort, { once: true }) - }) - } finally { - uncount() - } - } - if (isTerminal(task.status)) task.reported = true - return this.snapshot(task) - } + abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise /** * Register an effect-scoped completion listener. Each listener is contained; @@ -286,13 +120,7 @@ export class TaskService extends Service { * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ - onTaskDone(listener: TaskDoneListener): () => void { - const dispose = this.ctx.effect(() => { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - }, 'tasks.onTaskDone()') - return () => void dispose() - } + abstract onTaskDone(listener: TaskDoneListener): () => void /** * Attach an effect-scoped surface that can read and stop tasks. {@link start} @@ -300,149 +128,7 @@ export class TaskService extends Service { * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ - attachSurface(name: string): () => void { - // One token per call keeps duplicate labels independently disposable. - const token = Symbol(name) - const dispose = this.ctx.effect(() => { - this.surfaces.add(token) - return () => this.surfaces.delete(token) - }, 'tasks.attachSurface()') - return () => void dispose() - } - - /** Look up a task or fail loud. */ - private expect(id: TaskId): TrackedTask { - const task = this.store.get(id) - if (task === undefined) throw new Error(`unknown task ${id}`) - return task - } - - /** - * The isolation fence: a task with an owner is reachable only by callers - * whose session id matches (`!== undefined` semantics — an unowned task is - * open, and a no-agent caller can never match an owned one). - */ - private assertAccess(task: TrackedTask, caller?: Agent): void { - if (task.owner !== undefined && task.owner.id !== caller?.id) { - throw new Error(`task ${task.id} belongs to another session`) - } - } - - /** Project a fresh read-only snapshot from the mutable record. */ - private snapshot(task: TrackedTask): TaskSnapshot { - const ownerSession = task.owner?.id - return { - id: task.id, - kind: task.kind, - label: task.label, - ...task.outputLimitBytes !== undefined ? { outputLimitBytes: task.outputLimitBytes } : {}, - ...ownerSession !== undefined ? { ownerSession } : {}, - status: task.status, - ...task.detail !== undefined ? { detail: task.detail } : {}, - startedAt: task.startedAt, - ...task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {}, - reported: task.reported, - } - } - - /** - * Record the first terminal outcome, notify contained listeners, and release - * waiters. First-wins preserves a teardown force-failure against late producer - * settlement. Pending waits mark the task reported before listeners run. - */ - private settle(task: TrackedTask, outcome: TaskOutcome): void { - if (isTerminal(task.status)) return - task.status = outcome.status - task.detail = outcome.detail - task.output = outcome.output - task.finishedAt = Date.now() - if (task.waiters > 0) task.reported = true - if (!this.listenersClosed) { - const snapshot = this.snapshot(task) - for (const listener of this.listeners) { - try { - const returned = listener(snapshot, task.owner) - void Promise.resolve(returned).catch((error: unknown) => { - this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`) - }) - } catch (error: unknown) { - this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`) - } - } - } - const waitResolvers = [...task.waitResolvers] - task.waitResolvers.clear() - for (const resolveWait of waitResolvers) resolveWait() - task.markSettled() - } - - /** - * Attach one awaited cleanup through the exact owner's scope. This survives - * producer reloads and joins agent quiescence; the retained disposer lets - * service teardown detach the cross-fiber effect. Fails when the registry is - * absent or the owner is not its currently registered instance. - */ - private ensureOwnerCleanup(owner: Agent): void { - const ownerId = owner.id - const agents = this.selfCtx.get('agents') - if (agents === undefined) { - throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)') - } - if (agents.get(ownerId) !== owner) { - throw new Error(`agent "${ownerId}" is not the registered agent instance (background task owner must be live)`) - } - if (this.ownerCleanups.has(owner)) return - // Record only after attach succeeds; a disposing scope rejects new effects. - const detach = owner.ctx.effect(() => async () => { - this.ownerCleanups.delete(owner) - await this.disposeOwned(owner) - }, 'tasks.ownerCleanup()') - this.ownerCleanups.set(owner, detach) - } - - /** Cancel, await terminal records, and drop every task owned by one exact agent lifecycle. */ - private async disposeOwned(owner: Agent): Promise { - const owned = [...this.store.values()].filter(task => task.owner === owner) - this.cancelForTeardown(owned, 'owner disposed') - await Promise.all(owned.map(task => task.settled)) - for (const task of owned) this.store.delete(task.id) - } - - /** - * Close listeners, cancel live tasks, await settlement, and detach owner - * effects. Throwing cancels are force-failed to avoid teardown deadlock. - */ - private async disposeAll(): Promise { - this.listenersClosed = true - this.listeners.clear() - const all = [...this.store.values()] - this.cancelForTeardown(all, 'tasks service disposed') - await Promise.all(all.map(task => task.settled)) - this.store.clear() - // Detach cross-fiber owner effects after the shared store is quiescent. - const ownerCleanups = [...this.ownerCleanups.values()] - this.ownerCleanups.clear() - await Promise.all(ownerCleanups.map(cleanup => Promise.resolve(cleanup()))) - } - - /** - * Cancel tasks during teardown with per-task containment. A throwing cancel - * force-fails the record and reports a possible orphan; a cancel that returns - * without settling remains indistinguishable from a slow stop and may stall. - */ - private cancelForTeardown(tasks: TrackedTask[], reason: string): void { - for (const task of tasks) { - if (isTerminal(task.status)) continue - try { - task.cancel(reason) - task.status = 'stopping' - } catch (error: unknown) { - const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}` - this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`) - this.settle(task, { status: 'failed', detail }) - } - } - } + abstract attachSurface(name: string): () => void } export default TaskService diff --git a/packages/tasks/tasks/tests/service.spec.ts b/packages/tasks/tasks/tests/service.spec.ts new file mode 100644 index 0000000000..d8d582e410 --- /dev/null +++ b/packages/tasks/tasks/tests/service.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { TaskId, TaskService } from '@deepseek-ai/dsh-tasks' +import type { TaskDoneListener, TaskRead, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' + +/** + * Minimal concrete registry: one canned record. The seam owns the contract + * only (ids, snapshots, authorization-shaped signatures); the registry + * behavior suite lives with `@deepseek-ai/dsh-tasks-local`. + */ +class StubTaskService extends TaskService { + snapshotOf(id: TaskId): TaskSnapshot { + return { + id, + kind: 'bash', + label: 'sleep 60', + status: 'running', + startedAt: 0, + reported: false, + } + } + + start(spec: TaskStart): TaskId { + spec.run() + return TaskId(`${spec.kind}-1`) + } + + list(): TaskSnapshot[] { + return [this.snapshotOf(TaskId('bash-1'))] + } + + get(id: TaskId): TaskSnapshot { + return this.snapshotOf(id) + } + + read(id: TaskId): TaskRead { + return { text: '', snapshot: this.snapshotOf(id) } + } + + kill(): 'requested' | 'already-finished' { + return 'requested' + } + + wait(id: TaskId, _timeoutMs: number, _caller?: Agent, _signal?: AbortSignal): Promise { + return Promise.resolve(this.snapshotOf(id)) + } + + onTaskDone(_listener: TaskDoneListener): () => void { + return () => {} + } + + attachSurface(_name: string): () => void { + return () => {} + } +} + +describe('TaskService seam', () => { + it('a concrete subclass registers as ctx.tasks and serves the abstract API', async () => { + const ctx = new Context() + await ctx.plugin(StubTaskService) + + const detachSurface = ctx.tasks.attachSurface('seam-test') + const id = ctx.tasks.start({ kind: 'bash', label: 'sleep 60', run: () => ({ cancel() {}, done: new Promise(() => {}) }) }) + expect(id).toBe('bash-1') + expect(ctx.tasks.list()).toHaveLength(1) + expect(ctx.tasks.get(id).status).toBe('running') + expect(ctx.tasks.read(id).text).toBe('') + expect(ctx.tasks.kill(id)).toBe('requested') + await expect(ctx.tasks.wait(id, 5)).resolves.toMatchObject({ id }) + const detachListener = ctx.tasks.onTaskDone(() => {}) + detachListener() + detachSurface() + }) + + it('loading a second implementation throws (one tasks service per context — cordis standard)', async () => { + const ctx = new Context() + await ctx.plugin(StubTaskService) + class SecondTaskService extends StubTaskService {} + await expect(ctx.plugin(SecondTaskService)).rejects.toThrow(/service "tasks" has been registered/) + }) +}) diff --git a/packages/tasks/tasks/tsconfig.json b/packages/tasks/tasks/tsconfig.json index e29262ca74..75ade66b8c 100644 --- a/packages/tasks/tasks/tsconfig.json +++ b/packages/tasks/tasks/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../core/session" }, - { - "path": "../../util/timeout" - }, { "path": "../../support/invariants" } diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index 2e03fb26a2..2fd0b464a4 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index c41f498472..8494ff7f81 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -6,7 +6,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' -import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' +import { TaskId } from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { statusLine } from '@deepseek-ai/dsh-tool-tasks' @@ -20,7 +21,7 @@ async function setup(config: ToolTasks.Config = {}) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) const agentsFiber = await ctx.plugin(AgentRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) const toolsFiber = await ctx.plugin(ToolTasks, config) return { ctx, agentsFiber, toolsFiber } } @@ -91,7 +92,7 @@ describe('tool-tasks setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 })) .rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)') }) @@ -108,7 +109,7 @@ describe('tool-tasks setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) ToolTasks.apply(ctx, {}) expect(ctx.tools.get('task_output')).toBeDefined() expect(() => ctx.tasks.start(producer().spec)).not.toThrow() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7894b009ad..5ed728d64d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,9 +227,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt - '@deepseek-ai/dsh-tasks': + '@deepseek-ai/dsh-tasks-local': specifier: workspace:^ - version: link:../../packages/tasks/tasks + version: link:../../packages/tasks/tasks-local '@deepseek-ai/dsh-timeout-policy': specifier: workspace:^ version: link:../../packages/timeout/timeout-policy @@ -472,6 +472,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:* + version: link:../packages/tasks/tasks-local '@deepseek-ai/dsh-time-context': specifier: workspace:* version: link:../packages/context/time-context @@ -686,6 +689,9 @@ importers: '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../../tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local '@deepseek-ai/dsh-tool-tasks': specifier: workspace:^ version: link:../../tasks/tool-tasks @@ -1658,6 +1664,9 @@ importers: '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../../tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash @@ -2698,6 +2707,9 @@ importers: '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../../tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local '@deepseek-ai/dsh-tool-tasks': specifier: workspace:^ version: link:../../tasks/tool-tasks @@ -3612,6 +3624,9 @@ importers: '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../../tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local '@deepseek-ai/dsh-tool-tasks': specifier: workspace:^ version: link:../../tasks/tool-tasks @@ -3729,11 +3744,32 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/tasks/tasks-local: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../tasks '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout cordis: - specifier: ^4.0.0-rc.6 + specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/tasks/tool-tasks: @@ -3763,6 +3799,9 @@ importers: '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../tasks-local '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -4615,6 +4654,9 @@ importers: '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../../packages/tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../packages/tasks/tasks-local '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../packages/util/timeout diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 8a8d31c815..abf943793d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -66,6 +66,7 @@ "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 4c8817f318..2551f95df9 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -365,9 +365,10 @@ const SERVICE_ROLES: ServiceRole[] = [ key: 'tasks', pkg: 'tasks', title: 'Background task registry', - mode: 'core', + mode: 'seam', + implementations: ['tasks-local'], consumers: ['tool-bash', 'tool-pty', 'tool-subagent', 'tool-tasks'], - note: '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.', + note: '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.', }, { key: 'web', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3bbfd5b1ea..45aa58d74e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -29,7 +29,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' -import TaskService from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -355,7 +355,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'], writes: ['tool/call', 'tool/result', 'user/message via agent.inject() for background completion notices'], async mount(ctx) { - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) }, note: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d68df7adcd..16803b157f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -93,6 +93,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, + 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, 'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 3a5441120b..1aab67964a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -135,6 +135,7 @@ { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, { "path": "./packages/tasks/tasks" }, + { "path": "./packages/tasks/tasks-local" }, { "path": "./packages/tasks/tool-tasks" }, { "path": "./packages/workflow/workflow" }, { "path": "./packages/workflow/workflow-workerthread" }, From 4964e9c729818bc93dcbc7b1a3bcf885e3bebe0e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:15:47 +0800 Subject: [PATCH 08/19] test(web): navigation & panes scenarios over one rich seeded session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One two-turn seed (turn 1: bash + two parallel reads in a single assistant message; turn 2: a markdown-heavy reply) rendered cold through the seeded-history pattern — zero model calls — serving four surfaces: - sidebar search: client-side title filter; asserted only after the durable title lands with the attach baseline (a cold SessionSummary carries no title — search matches the displayTitle the user sees). Negative query empties the tree, positive narrows to the match + its force-expanded group, clear restores. - Trajectory tab: turn sections, the step group's tool mix ('bash read×2'), and a view-area aria golden. - Waterfall tab: span stats header + one lane per span. The P-I fold counts a turn-0 prologue span (only assistant/steering nodes carry a turn number) — pinned as-is; real spans are P-III per the view's ledger. - details column: the bash toolview row routes click to openDetails; open/closed is asserted on the frame's data-details-collapsed attribute because close collapses the grid column to width 0 without unmounting the subtree (hidden, not absent, is the contract). Agent Note scenario list extended in both languages; pairing re-recorded. --- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 1 + .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 1 + apps/web/tests/navigation-panes.e2e.ts | 179 ++++++++++++ .../snapshots/navigation-panes/seed.jsonl | 254 ++++++++++++++++++ .../navigation-panes/trajectory.expected.md | 1 + apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 8 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 apps/web/tests/navigation-panes.e2e.ts create mode 100644 apps/web/tests/snapshots/navigation-panes/seed.jsonl create mode 100644 apps/web/tests/snapshots/navigation-panes/trajectory.expected.md diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index dfab976c89..4591c046f1 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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 -2026-07-24-web-gui-browser-e2e-lane.md: 78a01652ef35f371c35c059033cd28f29f5bf94e -2026-07-24-web-gui-browser-e2e-lane.zh.md: b5fb29c63ab10352d50ef6ba9ce7b65e92989387 +2026-07-24-web-gui-browser-e2e-lane.md: f97bcfa77e3e6949945197cfe33abd7e1eec8008 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 3ec27956dd3c2ed985600d9e24f90155f99dc932 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 78a01652ef..f97bcfa77e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -47,6 +47,7 @@ The typecheck plane split is structural: the three files that boot the host spin 3. **`live-interactions`** — one tool-free recorded turn serves three replay-only scenarios through override sidecars whose CONTENT is authored in the spec and minted as a per-run file in a spec-owned temp dir (the derived success entry for the retry append is re-derived from the fixture via `deriveReplayScript`, never copied into a committed sidecar). Cancel: a `{ patches }` `hang` with a `readyFile` marker — the marker's existence proves the stream is parked mid-turn before the test clicks Stop, making mid-stream cancellation deterministic by construction (`turn/end` reason `aborted`, composer re-enabled). AUTH error: a pre-chunk `throw` outside llm-retry's retryable set (`turn/end` reason `error`, zero `llm/retry` events, composer recovers). SERVER retry: `throw` at call 0 + the fixture's own success appended at 1, proving llm-retry end-to-end in the browser via the durable `llm/retry` record (`request/header` logs only on change, so attempt count is invisible there). 4. **`question-composer`** — the shipped composition's resident `ask_user_question` takeover: a recorded turn blocks mid-step on the real userInteraction seam, the composer (`[data-question-key]`) renders in the browser, the test answers through it (the ONE sanctioned place a drive step reacts to model content: the turn cannot complete without the answer, in record and replay alike), and the tool result carries the chosen label. Golden: the composer's stable waiting state. 5. **`steering`** — mid-turn steer while the question composer blocks the step (the deterministic mid-turn window; no timing dependence). The composer locks while running, so the steer POSTs `session.prompt` `mode:'steer'` from the page over the same same-origin `/api` wire the client uses (`TODO(web-steer-composer)`: drive a composer gesture once one exists); everything downstream is product — gateway → `Agent.steer` → step-boundary drain → durable `steering/message` → SSE → badged interjection bubble. Record-mode fixture honesty: the recording is rejected unless the live model's final reply obeys an instruction only the steering message carries. +6. **`navigation-panes`** — one rich two-turn seed (turn 1: bash + two parallel reads in one assistant message; turn 2: a markdown-heavy reply) rendered cold through the seeded-history pattern (zero model calls), serving four surfaces: sidebar search (client-side title filter — asserted only after the durable title lands with the attach baseline, because a cold `SessionSummary` carries no title and search matches the `displayTitle` the user sees; negative query empties the tree, positive narrows, clear restores), the Trajectory tab (turn sections + the step group's tool mix plus the view-area aria golden), the Waterfall tab (span stats + one lane per span — the P-I fold counts a turn-0 prologue span because only assistant/steering nodes carry a turn number, pinned as-is), and the details column (the bash toolview row routes click to openDetails; open/closed is asserted on the frame's `data-details-collapsed` attribute because close collapses the grid column to width 0 without unmounting the subtree). ### CI stance diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index b5fb29c63a..3ec27956dd 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -47,6 +47,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 3. **`live-interactions`**——一段不含工具调用的已录轮次经覆写 sidecar 承载三个仅回放的场景:sidecar 的内容本身写在 spec 里,每次运行时在 spec 自有的临时目录中生成文件(重试追加所用的派生成功条目经 `deriveReplayScript` 从 fixture 重新派生,绝不复制进已提交的 sidecar)。取消:一个带 `readyFile` 标记的 `{ patches }` `hang`——标记文件的存在证明流在测试点击 Stop 之前已停驻在轮次中途,使流中取消按构造即确定(`turn/end` 原因为 `aborted`,输入框重新启用)。AUTH 错误:一次落在 llm-retry 可重试集合之外的分片前 `throw`(`turn/end` 原因为 `error`,零条 `llm/retry` 事件,输入框恢复可用)。SERVER 重试:第 0 次调用 `throw` + 在第 1 次调用处追加 fixture 自身的成功条目,凭持久的 `llm/retry` 记录在浏览器中端到端证明 llm-retry(`request/header` 仅在变化时记录,因此尝试次数在那里不可见)。 4. **`question-composer`**——已交付组合中常驻的 `ask_user_question` 接管:一段已录轮次在真实的 userInteraction seam 上阻塞于步骤中途,提问输入框(`[data-question-key]`)在浏览器中渲染,测试经它作答(这是驱动步骤对模型内容作出反应的唯一获准之处:没有这个回答,轮次无法完成,record 与 replay 皆然),工具结果携带所选的 label。预期输出:提问输入框稳定的等待态。 5. **`steering`**——在提问输入框阻塞该步骤时做轮次中途 steering(中途引导),此即确定性的轮次中途窗口,不依赖任何时序。输入框在运行期间锁定,因此这一 steer 由页面经客户端所用的同一条同源 `/api` wire POST `session.prompt` `mode:'steer'`(`TODO(web-steer-composer)`:待有输入框手势后改为驱动它);下游的一切都是产品路径——gateway → `Agent.steer` → 步骤边界排空 → 持久的 `steering/message` → SSE → 带徽标的插话气泡。record 模式的 fixture 诚实性:除非真实模型的最终回复遵循了一条只有 steering 消息才携带的指令,否则该次录制被拒绝。 +6. **`navigation-panes`**——一份内容丰富的双轮次种子(轮次 1:同一条 assistant 消息内的 bash + 两次并行 read;轮次 2:一段 markdown 密集的回复)经 seeded-history 模式冷渲染(零模型调用),承载四个表面:侧栏搜索(客户端标题过滤;仅在持久的标题随 attach 基线一同到达后才断言,因为冷的 `SessionSummary` 不携带标题,而搜索匹配的是用户所见的 `displayTitle`;反例查询清空整棵树,正例查询收窄,清除后复原)、Trajectory 标签页(轮次分节 + 步骤组的工具构成,外加视图区 aria 预期输出)、Waterfall 标签页(span 统计 + 每个 span 一条泳道;只有 assistant/steering 节点携带轮次编号,因此 P-I 折叠会将一个轮次 0 的序幕 span 计入,按原样钉住)与详情列(bash 工具视图行把点击路由到 openDetails;打开/关闭状态断言在 frame 的 `data-details-collapsed` 属性上,因为关闭把网格列收缩到宽度 0 而不卸载子树)。 ### CI 立场 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts new file mode 100644 index 0000000000..2147ef9cdd --- /dev/null +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -0,0 +1,179 @@ +// 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 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) + } + }, 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) + 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']) + }) +}) 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..612971ce7a --- /dev/null +++ b/apps/web/tests/snapshots/navigation-panes/seed.jsonl @@ -0,0 +1,254 @@ +{"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":"assistant/chunk","seq":6,"time":1785011380917,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785011381027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785011381052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785011381053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785011381053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785011381053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":12,"time":1785011381078,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":13,"time":1785011381079,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":14,"time":1785011381079,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" navigation"}}} +{"type":"assistant/chunk","seq":15,"time":1785011381105,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" scenario"}}} +{"type":"assistant/chunk","seq":16,"time":1785011381105,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":17,"time":1785011381106,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":18,"time":1785011381106,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":19,"time":1785011381133,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":20,"time":1785011381134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":21,"time":1785011381134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1785011381134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Run"}}} +{"type":"assistant/chunk","seq":23,"time":1785011381134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":24,"time":1785011381160,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":25,"time":1785011381161,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" print"}}} +{"type":"assistant/chunk","seq":26,"time":1785011381161,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":27,"time":1785011381161,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"NA"}}} +{"type":"assistant/chunk","seq":28,"time":1785011381187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"V"}}} +{"type":"assistant/chunk","seq":29,"time":1785011381187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"IG"}}} +{"type":"assistant/chunk","seq":30,"time":1785011381187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ATION"}}} +{"type":"assistant/chunk","seq":31,"time":1785011381187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":32,"time":1785011381187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n"}}} +{"type":"assistant/chunk","seq":33,"time":1785011381188,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":34,"time":1785011381213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":35,"time":1785011381213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Read"}}} +{"type":"assistant/chunk","seq":36,"time":1785011381213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nav"}}} +{"type":"assistant/chunk","seq":37,"time":1785011381238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-a"}}} +{"type":"assistant/chunk","seq":38,"time":1785011381238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".md"}}} +{"type":"assistant/chunk","seq":39,"time":1785011381239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":40,"time":1785011381239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nav"}}} +{"type":"assistant/chunk","seq":41,"time":1785011381239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-b"}}} +{"type":"assistant/chunk","seq":42,"time":1785011381239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".md"}}} +{"type":"assistant/chunk","seq":43,"time":1785011381265,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":44,"time":1785011381266,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":45,"time":1785011381266,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":46,"time":1785011381291,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} +{"type":"assistant/chunk","seq":47,"time":1785011381291,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":48,"time":1785011381291,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ONE"}}} +{"type":"assistant/chunk","seq":49,"time":1785011381318,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" message"}}} +{"type":"assistant/chunk","seq":50,"time":1785011381319,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":51,"time":1785011381319,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":52,"time":1785011381319,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":53,"time":1785011381319,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Reply"}}} +{"type":"assistant/chunk","seq":54,"time":1785011381319,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":55,"time":1785011381344,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":56,"time":1785011381372,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":57,"time":1785011381372,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":58,"time":1785011381373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":59,"time":1785011381373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":60,"time":1785011381373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} +{"type":"assistant/chunk","seq":61,"time":1785011381373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":62,"time":1785011381400,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":63,"time":1785011381400,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":64,"time":1785011381400,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":65,"time":1785011381400,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":66,"time":1785011381425,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":67,"time":1785011381426,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":68,"time":1785011381450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":69,"time":1785011381451,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":70,"time":1785011381451,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}} +{"type":"assistant/chunk","seq":71,"time":1785011381476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":72,"time":1785011381556,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":73,"time":1785011381557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":74,"time":1785011381557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":75,"time":1785011381557,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":76,"time":1785011381583,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":77,"time":1785011381583,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1785011381583,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":79,"time":1785011381583,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":80,"time":1785011381608,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":81,"time":1785011381609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":" NAV"}}} +{"type":"assistant/chunk","seq":82,"time":1785011381635,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"IG"}}} +{"type":"assistant/chunk","seq":83,"time":1785011381635,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"ATION"}}} +{"type":"assistant/chunk","seq":84,"time":1785011381635,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":85,"time":1785011381636,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":86,"time":1785011381669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":87,"time":1785011381670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":88,"time":1785011381687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":89,"time":1785011381687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":90,"time":1785011381687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":91,"time":1785011381687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":92,"time":1785011381715,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"Print"}}} +{"type":"assistant/chunk","seq":93,"time":1785011381716,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":" NAV"}}} +{"type":"assistant/chunk","seq":94,"time":1785011381716,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"IG"}}} +{"type":"assistant/chunk","seq":95,"time":1785011381716,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"ATION"}}} +{"type":"assistant/chunk","seq":96,"time":1785011381716,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":97,"time":1785011381740,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":98,"time":1785011381741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":99,"time":1785011381793,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":100,"time":1785011381793,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":101,"time":1785011381819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":102,"time":1785011381819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":103,"time":1785011381819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":104,"time":1785011381819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":105,"time":1785011381820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":106,"time":1785011381847,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":107,"time":1785011381847,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":108,"time":1785011381847,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"nav"}}} +{"type":"assistant/chunk","seq":109,"time":1785011381847,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"-a"}}} +{"type":"assistant/chunk","seq":110,"time":1785011381873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":".md"}}} +{"type":"assistant/chunk","seq":111,"time":1785011381874,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":112,"time":1785011381897,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":113,"time":1785011381924,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":3,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":114,"time":1785011381924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":115,"time":1785011381950,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":116,"time":1785011381951,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":117,"time":1785011381951,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":118,"time":1785011381977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":119,"time":1785011381977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":120,"time":1785011381977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":121,"time":1785011381977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":122,"time":1785011382003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"nav"}}} +{"type":"assistant/chunk","seq":123,"time":1785011382003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"-b"}}} +{"type":"assistant/chunk","seq":124,"time":1785011382003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":".md"}}} +{"type":"assistant/chunk","seq":125,"time":1785011382003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":126,"time":1785011382029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":3,"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","argumentsDelta":"}"}}} +{"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":"assistant/chunk","seq":143,"time":1785011382673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"All"}}} +{"type":"assistant/chunk","seq":144,"time":1785011382763,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" three"}}} +{"type":"assistant/chunk","seq":145,"time":1785011382790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} +{"type":"assistant/chunk","seq":146,"time":1785011382817,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} +{"type":"assistant/chunk","seq":147,"time":1785011382818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":148,"time":1785011382818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":149,"time":1785011382818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":150,"time":1785011382818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":151,"time":1785011382844,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" printed"}}} +{"type":"assistant/chunk","seq":152,"time":1785011382845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":153,"time":1785011382845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"NA"}}} +{"type":"assistant/chunk","seq":154,"time":1785011382845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"V"}}} +{"type":"assistant/chunk","seq":155,"time":1785011382845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"IG"}}} +{"type":"assistant/chunk","seq":156,"time":1785011382845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ATION"}}} +{"type":"assistant/chunk","seq":157,"time":1785011382872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":158,"time":1785011382872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n"}}} +{"type":"assistant/chunk","seq":159,"time":1785011382872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":160,"time":1785011382872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":161,"time":1785011382872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nav"}}} +{"type":"assistant/chunk","seq":162,"time":1785011382899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-a"}}} +{"type":"assistant/chunk","seq":163,"time":1785011382899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".md"}}} +{"type":"assistant/chunk","seq":164,"time":1785011382899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":165,"time":1785011382926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \"#"}}} +{"type":"assistant/chunk","seq":166,"time":1785011382926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" alpha"}}} +{"type":"assistant/chunk","seq":167,"time":1785011382926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nav"}}} +{"type":"assistant/chunk","seq":168,"time":1785011382926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n"}}} +{"type":"assistant/chunk","seq":169,"time":1785011382926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":170,"time":1785011382927,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":171,"time":1785011382952,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nav"}}} +{"type":"assistant/chunk","seq":172,"time":1785011382953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-b"}}} +{"type":"assistant/chunk","seq":173,"time":1785011382953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".md"}}} +{"type":"assistant/chunk","seq":174,"time":1785011382953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":175,"time":1785011382953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \"#"}}} +{"type":"assistant/chunk","seq":176,"time":1785011382953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" beta"}}} +{"type":"assistant/chunk","seq":177,"time":1785011382979,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nav"}}} +{"type":"assistant/chunk","seq":178,"time":1785011382980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} +{"type":"assistant/chunk","seq":179,"time":1785011382980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":180,"time":1785011382980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":181,"time":1785011382980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":182,"time":1785011382980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":183,"time":1785011383005,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":184,"time":1785011383006,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":185,"time":1785011383006,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":186,"time":1785011383032,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":187,"time":1785011383033,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":188,"time":1785011383033,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":189,"time":1785011383033,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":190,"time":1785011383033,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":191,"time":1785011383033,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":192,"time":1785011383059,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":193,"time":1785011383060,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":194,"time":1785011383060,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":195,"time":1785011383060,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} +{"type":"assistant/chunk","seq":196,"time":1785011383060,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} +{"type":"assistant/chunk","seq":197,"time":1785011383060,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":198,"time":1785011383089,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"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":"assistant/chunk","seq":210,"time":1785011383497,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":211,"time":1785011383622,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":212,"time":1785011383645,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":213,"time":1785011383646,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":214,"time":1785011383646,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":215,"time":1785011383646,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":216,"time":1785011383734,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":217,"time":1785011383734,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":218,"time":1785011383734,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":219,"time":1785011383739,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" format"}}} +{"type":"assistant/chunk","seq":220,"time":1785011383739,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":221,"time":1785011383740,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":222,"time":1785011383740,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":223,"time":1785011383740,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":224,"time":1785011383747,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":225,"time":1785011383747,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":226,"time":1785011383748,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":227,"time":1785011383748,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"##"}}} +{"type":"assistant/chunk","seq":228,"time":1785011383772,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":" Navigation"}}} +{"type":"assistant/chunk","seq":229,"time":1785011383773,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":" Summary"}}} +{"type":"assistant/chunk","seq":230,"time":1785011383798,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"\n\n"}}} +{"type":"assistant/chunk","seq":231,"time":1785011383798,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"-"}}} +{"type":"assistant/chunk","seq":232,"time":1785011383798,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":" alpha"}}} +{"type":"assistant/chunk","seq":233,"time":1785011383823,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":" nav"}}} +{"type":"assistant/chunk","seq":234,"time":1785011383849,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":235,"time":1785011383850,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"-"}}} +{"type":"assistant/chunk","seq":236,"time":1785011383850,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":" beta"}}} +{"type":"assistant/chunk","seq":237,"time":1785011383850,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":" nav"}}} +{"type":"assistant/chunk","seq":238,"time":1785011383850,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"\n\n"}}} +{"type":"assistant/chunk","seq":239,"time":1785011383875,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":240,"time":1785011383875,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"echo"}}} +{"type":"assistant/chunk","seq":241,"time":1785011383875,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":" WATER"}}} +{"type":"assistant/chunk","seq":242,"time":1785011383875,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"F"}}} +{"type":"assistant/chunk","seq":243,"time":1785011383876,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ALL"}}} +{"type":"assistant/chunk","seq":244,"time":1785011383902,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":245,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"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/tsconfig.json b/apps/web/tsconfig.json index fa92bde8ea..9a0181dee9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -26,6 +26,7 @@ "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/steering.e2e.ts", + "tests/navigation-panes.e2e.ts", "tests/replay-round-trip.e2e.ts", "tests/seeded-history.e2e.ts" ], diff --git a/tsconfig.host.json b/tsconfig.host.json index 988b7de560..c4aae9a907 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -13,6 +13,7 @@ "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/steering.e2e.ts", + "apps/web/tests/navigation-panes.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/cli/tests/**/*.ts", From b61a5ff5e3240d508cdfb953264ddd32e185ea3e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:35:04 +0800 Subject: [PATCH 09/19] docs(tasks): bilingual pair for the task-registry seam Agent Note Adds the Chinese counterpart of the new seam note, records both pairs (new note + the updated background-task runtime note), and ratchets the translation-pairing manifest. --- ...-20-generic-long-running-tool-runtime.i18n.yaml | 4 ++-- ...6-06-20-generic-long-running-tool-runtime.zh.md | 4 ++-- .../2026-07-26-task-registry-seam.i18n.yaml | 6 ++++++ .../2026-07-26-task-registry-seam.zh.md | 14 +++++++------- scripts/translation-pairing.manifest.json | 5 +++-- 5 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index d44e3ffee9..db80fbcfa9 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.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 -2026-06-20-generic-long-running-tool-runtime.md: 0b901fcf928b900bd3a32f911e6e54a6a98076e2 -2026-06-20-generic-long-running-tool-runtime.zh.md: e2860e3a91c06ec5110cd671b288e35c5d117f5d +2026-06-20-generic-long-running-tool-runtime.md: 313d687b49da0d08b0ec321bcb655b642f7a5af3 +2026-06-20-generic-long-running-tool-runtime.zh.md: 6be129b7b16ff01d73dc94f7ce6d299ee2c10e55 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index 39900e24ba..6be129b7b1 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -19,7 +19,7 @@ Status: implemented 长时间运行工具是生产方。`dsh-tool-bash` 将 `BashProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。执行 seam 保持独立,不依赖会话或任务注册表。 -`TaskService` 是 `@deepseek-ai/dsh-tasks` 中的抽象 seam;进程内注册表是 `@deepseek-ai/dsh-tasks-local` 中的 `LocalTaskService`(该拆分记录在[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.zh.md)中)。 +`TaskService` 是 `@deepseek-ai/dsh-tasks` 中的抽象 seam;进程内注册表是 `@deepseek-ai/dsh-tasks-local` 中的 `LocalTaskService`(该拆分记录在[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.md)中)。 ## 运行时契约 @@ -103,7 +103,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas ### 立即抽象任务运行时后端 -当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.zh.md)后来在不改变这些进程内语义的前提下,将契约与进程内实现分离。 +当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.md)后来在不改变这些进程内语义的前提下,将契约与进程内实现分离。 ### 由消费方负责授权或清理事件 diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml new file mode 100644 index 0000000000..e7c39e376a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.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 +2026-07-26-task-registry-seam.md: b785eb75a632503def10fad583f6a68477cffef6 +2026-07-26-task-registry-seam.zh.md: 3d2426b0208afbbebe51254e43cae64cad12f11a diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index aa4df43b82..3d2426b020 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -6,30 +6,30 @@ Status: implemented ## 问题 -[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有所有生产方和控制接口面向编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除逻辑)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力(bash、pty、fs、skill、subagent、web、会话持久化)都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。 +[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制接口面向其编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。 ## 决策 `tasks/` 如今是一个 bash 三件套形态的三包能力家族: - **`@deepseek-ai/dsh-tasks`(接口)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的契约(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个实现都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且在没有附加任何控制接口时 `start` 拒绝启动工作。 -- **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除逻辑。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。 +- **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。 - **`@deepseek-ai/dsh-tool-tasks`(消费方)**——保持不变;它注入 `'tasks'`,从不导入实现类型。 -各组合配置在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`(CLI 的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness、工具目录生成器的启动流程)。生产方的配置错误诊断信息("background tasks unavailable: load …")点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 +各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)应用的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息("background tasks unavailable: load …")点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 该 seam 保持进程内契约语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能实现此接口之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个消费方的依赖图;它并不预先设计后端。 ## 曾考虑的替代方案 -**在第二个后端出现之前保持具体服务(维持现状)。**这正是当初运行时 Agent Note 的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经在面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里,而若维持合并包的现状,它们还会连带搅动每个消费方的实现依赖。 +**在第二个后端出现之前保持具体服务(维持现状)。**这正是当初运行时 Agent Note 的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经面向其编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里;而若维持现状,它们今天还会连带搅动每个消费方的实现依赖。 -**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决:它在运作层面并未分离任何东西。消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。 +**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决,因为它在运作层面并未分离任何东西:消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。 **拆出 `types.ts` 但让服务保持具体。**基于同样的理由否决:类型并不是 seam,`ctx.tasks` 及其方法契约才是。生产方需要的是服务键和语义,而不只是类型形状。 ## 后果 -换来的是:任务注册表如今与全仓库统一的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的后端落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;实现包的 README 拥有生命周期簿记的相关事实。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个基于桩子类的测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 +换来的是:任务注册表如今与全仓库统一的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的后端落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;实现包的 README 拥有生命周期簿记的相关事实。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个基于桩子类(stub subclass)的测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 -代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合配置必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,得到的将是挂起的 `ctx.tasks`,生产方将按标准的服务缺失行为失败,而不会得到一条专门定制的消息。若推荐的默认后端日后换成其他实现,点名 `dsh-tasks-local` 的配置错误诊断信息会随之陈旧;这一代价已被接受。 +代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这是已接受的代价。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 300a213484..cbc39c0bde 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -43,6 +43,7 @@ ".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md", ".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md", ".agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md", + ".agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md", ".agents/notes/implemented/feature/2026-06-14-acp-multi-session.md", ".agents/notes/implemented/feature/2026-06-15-code-mode.md", ".agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md", @@ -66,6 +67,7 @@ ".agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md", ".agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md", ".agents/notes/implemented/feature/2026-07-10-session-query-service.md", + ".agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md", ".agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md", ".agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.md", ".agents/notes/implemented/process/2026-06-11-quality-gates.md", @@ -128,8 +130,6 @@ ".agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md", ".agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md", ".agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md", - ".agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md", - ".agents/notes/rejected/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md", ".agents/notes/proposed/process/2026-06-11-api-extractor-reports.md", ".agents/notes/proposed/process/2026-06-11-architectural-conformance.md", ".agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md", @@ -139,6 +139,7 @@ ".agents/notes/proposed/testing/2026-06-11-mutation-testing.md", ".agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.md", ".agents/notes/rejected/architecture/2026-06-20-providerless-example-base.md", + ".agents/notes/rejected/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md", ".agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.md", ".agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md", ".agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.md", From 71c564d801b977ade24deba1903dad8cd0bfd2a5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:07:35 +0800 Subject: [PATCH 10/19] docs(tasks): final translation pass on the seam note zh counterpart --- .../2026-07-26-task-registry-seam.i18n.yaml | 2 +- .../architecture/2026-07-26-task-registry-seam.zh.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml index e7c39e376a..409bc30c12 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-26-task-registry-seam.md: b785eb75a632503def10fad583f6a68477cffef6 -2026-07-26-task-registry-seam.zh.md: 3d2426b0208afbbebe51254e43cae64cad12f11a +2026-07-26-task-registry-seam.zh.md: bfb733a5e1060c9bfe2acc6c4769aa47443d0c9e diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index 3d2426b020..bfb733a5e1 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制接口面向其编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。 +[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制接口面向编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。 ## 决策 @@ -16,20 +16,20 @@ Status: implemented - **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。 - **`@deepseek-ai/dsh-tool-tasks`(消费方)**——保持不变;它注入 `'tasks'`,从不导入实现类型。 -各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)应用的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息("background tasks unavailable: load …")点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 +各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 该 seam 保持进程内契约语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能实现此接口之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个消费方的依赖图;它并不预先设计后端。 ## 曾考虑的替代方案 -**在第二个后端出现之前保持具体服务(维持现状)。**这正是当初运行时 Agent Note 的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经面向其编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里;而若维持现状,它们今天还会连带搅动每个消费方的实现依赖。 +**在第二个后端出现之前保持具体服务(维持现状)。**这正是运行时 Agent Note 当初的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里;而若维持现状,它们今天还会连带搅动每个消费方的实现依赖。 -**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决,因为它在运作层面并未分离任何东西:消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。 +**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决,因为它在运作层面并未分离任何东西:消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入自身依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。 **拆出 `types.ts` 但让服务保持具体。**基于同样的理由否决:类型并不是 seam,`ctx.tasks` 及其方法契约才是。生产方需要的是服务键和语义,而不只是类型形状。 ## 后果 -换来的是:任务注册表如今与全仓库统一的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的后端落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;实现包的 README 拥有生命周期簿记的相关事实。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个基于桩子类(stub subclass)的测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 +换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 -代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这是已接受的代价。 +代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。 From cbb5fc7a51ba9c516cebe7ef6e1abb0e814864c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:08:08 +0800 Subject: [PATCH 11/19] =?UTF-8?q?test(web):=20lifecycle=20&=20chrome=20sce?= =?UTF-8?q?narios=20=E2=80=94=20workspace=20flow,=20reload=20recovery,=20d?= =?UTF-8?q?ark=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One tiny recorded text turn drives three whole-page concerns: - workspace flow over the real wire: the empty-state hero's first send materializes a real Workspace + Session (the jsdom workspace-flow suite pins this state machine over the fixture client; this scenario pins it through HTTP RPC + SSE + the gateway). Durable proof: the session header's cwd is the create-by-name target /workspace. Adds the hero waiting-state aria golden. - reload recovery: collapse the sidebar (persisted dsh.layout.panels), page.reload, and the surface comes back whole from persistence alone — layout collapsed, selection restored (dsh.sessions.current), the recorded turn re-rendered from session.history with zero model calls (the drained replay cursor makes any stray request fail loud at close). - dark mode: no product control flips the theme yet, so the scenario drives the ThemeService's entire DOM contract — body[data-ds-dark-theme] — and pins the shipped cascade: the alias token flips, a painted surface repaints, and removing the attribute restores the light sample exactly. TODO(web-theme-gesture) upgrades to a real settings control; no theme golden per the lane's scope ruling (aria is color-blind). Agent Note scenario list extended in both languages; pairing re-recorded. --- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 1 + .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 1 + apps/web/tests/lifecycle-chrome.e2e.ts | 152 ++++++++++++++++++ .../lifecycle-chrome/hero.expected.md | 35 ++++ .../snapshots/lifecycle-chrome/session.jsonl | 35 ++++ apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 8 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 apps/web/tests/lifecycle-chrome.e2e.ts create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/session.jsonl diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 4591c046f1..bf6ca9d0d7 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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 -2026-07-24-web-gui-browser-e2e-lane.md: f97bcfa77e3e6949945197cfe33abd7e1eec8008 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 3ec27956dd3c2ed985600d9e24f90155f99dc932 +2026-07-24-web-gui-browser-e2e-lane.md: 88730cdecf527ece8033ddab1151afcbc6edd83f +2026-07-24-web-gui-browser-e2e-lane.zh.md: 9850023a49a860a8f4bbdacc8c48fc389ec77210 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index f97bcfa77e..88730cdecf 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -48,6 +48,7 @@ The typecheck plane split is structural: the three files that boot the host spin 4. **`question-composer`** — the shipped composition's resident `ask_user_question` takeover: a recorded turn blocks mid-step on the real userInteraction seam, the composer (`[data-question-key]`) renders in the browser, the test answers through it (the ONE sanctioned place a drive step reacts to model content: the turn cannot complete without the answer, in record and replay alike), and the tool result carries the chosen label. Golden: the composer's stable waiting state. 5. **`steering`** — mid-turn steer while the question composer blocks the step (the deterministic mid-turn window; no timing dependence). The composer locks while running, so the steer POSTs `session.prompt` `mode:'steer'` from the page over the same same-origin `/api` wire the client uses (`TODO(web-steer-composer)`: drive a composer gesture once one exists); everything downstream is product — gateway → `Agent.steer` → step-boundary drain → durable `steering/message` → SSE → badged interjection bubble. Record-mode fixture honesty: the recording is rejected unless the live model's final reply obeys an instruction only the steering message carries. 6. **`navigation-panes`** — one rich two-turn seed (turn 1: bash + two parallel reads in one assistant message; turn 2: a markdown-heavy reply) rendered cold through the seeded-history pattern (zero model calls), serving four surfaces: sidebar search (client-side title filter — asserted only after the durable title lands with the attach baseline, because a cold `SessionSummary` carries no title and search matches the `displayTitle` the user sees; negative query empties the tree, positive narrows, clear restores), the Trajectory tab (turn sections + the step group's tool mix plus the view-area aria golden), the Waterfall tab (span stats + one lane per span — the P-I fold counts a turn-0 prologue span because only assistant/steering nodes carry a turn number, pinned as-is), and the details column (the bash toolview row routes click to openDetails; open/closed is asserted on the frame's `data-details-collapsed` attribute because close collapses the grid column to width 0 without unmounting the subtree). +7. **`lifecycle-chrome`** — one tiny recorded text turn drives three whole-page concerns. Workspace flow over the real wire: the empty-state hero's first send materializes a real Workspace + Session (the jsdom `workspace-flow.snapshot.ts` suite pins this state machine over the fixture client; this scenario pins it through HTTP RPC + SSE + the gateway), proven durably by the session header's cwd being the create-by-name target `/workspace`, plus the hero waiting-state aria golden. Reload recovery: collapse the sidebar (persisted `dsh.layout.panels`), `page.reload`, and the surface comes back whole from persistence alone — layout collapsed, selection restored (`dsh.sessions.current`), the recorded turn re-rendered from `session.history` with zero model calls (the drained replay cursor makes any stray request fail loud at close). Dark mode: no product control flips the theme yet, so the scenario drives the ThemeService's entire DOM contract — the `body[data-ds-dark-theme]` attribute — and pins the shipped cascade: the alias token flips, a painted surface repaints, and removing the attribute restores the light sample exactly (`TODO(web-theme-gesture)` upgrades to a real settings control); per the scope ruling there is no theme/layout golden (aria is color-blind). ### CI stance diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 3ec27956dd..9850023a49 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -48,6 +48,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 4. **`question-composer`**——已交付组合中常驻的 `ask_user_question` 接管:一段已录轮次在真实的 userInteraction seam 上阻塞于步骤中途,提问输入框(`[data-question-key]`)在浏览器中渲染,测试经它作答(这是驱动步骤对模型内容作出反应的唯一获准之处:没有这个回答,轮次无法完成,record 与 replay 皆然),工具结果携带所选的 label。预期输出:提问输入框稳定的等待态。 5. **`steering`**——在提问输入框阻塞该步骤时做轮次中途 steering(中途引导),此即确定性的轮次中途窗口,不依赖任何时序。输入框在运行期间锁定,因此这一 steer 由页面经客户端所用的同一条同源 `/api` wire POST `session.prompt` `mode:'steer'`(`TODO(web-steer-composer)`:待有输入框手势后改为驱动它);下游的一切都是产品路径——gateway → `Agent.steer` → 步骤边界排空 → 持久的 `steering/message` → SSE → 带徽标的插话气泡。record 模式的 fixture 诚实性:除非真实模型的最终回复遵循了一条只有 steering 消息才携带的指令,否则该次录制被拒绝。 6. **`navigation-panes`**——一份内容丰富的双轮次种子(轮次 1:同一条 assistant 消息内的 bash + 两次并行 read;轮次 2:一段 markdown 密集的回复)经 seeded-history 模式冷渲染(零模型调用),承载四个表面:侧栏搜索(客户端标题过滤;仅在持久的标题随 attach 基线一同到达后才断言,因为冷的 `SessionSummary` 不携带标题,而搜索匹配的是用户所见的 `displayTitle`;反例查询清空整棵树,正例查询收窄,清除后复原)、Trajectory 标签页(轮次分节 + 步骤组的工具构成,外加视图区 aria 预期输出)、Waterfall 标签页(span 统计 + 每个 span 一条泳道;只有 assistant/steering 节点携带轮次编号,因此 P-I 折叠会将一个轮次 0 的序幕 span 计入,按原样钉住)与详情列(bash 工具视图行把点击路由到 openDetails;打开/关闭状态断言在 frame 的 `data-details-collapsed` 属性上,因为关闭把网格列收缩到宽度 0 而不卸载子树)。 +7. **`lifecycle-chrome`**——一段极小的已录纯文本轮次驱动三个整页关注点。真实 wire 上的 Workspace 动线:空态 hero 的首次发送物化出真实的 Workspace + Session(jsdom 的 `workspace-flow.snapshot.ts` 套件基于 fixture 客户端钉住这一状态机;本场景则经 HTTP RPC + SSE + gateway 钉住它),其持久证据是会话头部的 cwd 恰为按名创建的目标 `/workspace`,外加 hero 等待态的 aria 预期输出。重新加载恢复:折叠侧栏(持久化于 `dsh.layout.panels`),`page.reload`,整个表面纯凭持久化完整归来——布局保持折叠,选中项恢复(`dsh.sessions.current`),已录轮次从 `session.history` 重新渲染且零模型调用(已耗尽的回放游标使任何离群请求都在 close 时大声失败)。暗色模式:产品尚无任何控件能切换主题,因此本场景驱动 ThemeService 的整个 DOM 契约(即 `body[data-ds-dark-theme]` 属性),并钉住已交付的级联:alias token 翻转,某个实际绘制的表面重绘,移除该属性则精确还原亮色采样值(`TODO(web-theme-gesture)`:待有真实设置控件后升级为驱动它);按范围裁定,主题/布局不设预期输出(aria 感知不到颜色)。 ### CI 立场 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts new file mode 100644 index 0000000000..2ab4f5aeea --- /dev/null +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -0,0 +1,152 @@ +// 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 { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { 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') +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 }) + }, 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) + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // 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) + 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')) + // No product control flips the theme yet — the ThemeService's whole DOM + // contract is the body[data-ds-dark-theme] attribute, so the scenario + // drives exactly that seam and pins the shipped stylesheet's cascade. + // TODO(web-theme-gesture): drive a real settings control once one exists. + 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 () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md']) + }) +}) 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..55317addcb --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -0,0 +1,35 @@ +- 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": No sessions yet +- button "Settings": + - img + - text: Settings +- text: Let's start building +- button "Choose workspace": + - img + - text: workspace + - img +- textbox "Describe what you want to build" +- 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/session.jsonl b/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl new file mode 100644 index 0000000000..07814d13fe --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl @@ -0,0 +1,35 @@ +{"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":"assistant/chunk","seq":6,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785015040092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785015040120,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785015040121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785015040121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785015040121,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":12,"time":1785015040167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":13,"time":1785015040168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":14,"time":1785015040168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":15,"time":1785015040168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":16,"time":1785015040168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":17,"time":1785015040179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":18,"time":1785015040179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":19,"time":1785015040179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" comply"}}} +{"type":"assistant/chunk","seq":20,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"L"}}} +{"type":"assistant/chunk","seq":23,"time":1785015040210,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"IGH"}}} +{"type":"assistant/chunk","seq":24,"time":1785015040210,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"TH"}}} +{"type":"assistant/chunk","seq":25,"time":1785015040240,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"O"}}} +{"type":"assistant/chunk","seq":26,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"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/tsconfig.json b/apps/web/tsconfig.json index 9a0181dee9..55ad95ffdb 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -27,6 +27,7 @@ "tests/question-composer.e2e.ts", "tests/steering.e2e.ts", "tests/navigation-panes.e2e.ts", + "tests/lifecycle-chrome.e2e.ts", "tests/replay-round-trip.e2e.ts", "tests/seeded-history.e2e.ts" ], diff --git a/tsconfig.host.json b/tsconfig.host.json index c4aae9a907..63f1c835b9 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -14,6 +14,7 @@ "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts", + "apps/web/tests/lifecycle-chrome.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/cli/tests/**/*.ts", From 3b911359232d784ca336c07d54b1bb7c2e893d66 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:25:47 +0800 Subject: [PATCH 12/19] fix(tasks): fail loud when the abstract seam is mounted directly Review finding (Codex round 1): abstract erases at runtime and @deepseek-ai/dsh-tasks used to be the mountable registry, so a stale composition row would register a ctx.tasks with no method implementations and fail far from the misconfiguration. The seam constructor now rejects direct mounts with a load-time pointer at dsh-tasks-local; the seam suite pins the fence, the Agent Note cost paragraph records the actual behavior, and the stale tool-pty README requirement line names the implementation package. --- .../architecture/2026-07-26-task-registry-seam.i18n.yaml | 4 ++-- .../architecture/2026-07-26-task-registry-seam.md | 2 +- .../architecture/2026-07-26-task-registry-seam.zh.md | 2 +- packages/pty/tool-pty/README.md | 2 +- packages/tasks/tasks/src/index.ts | 7 +++++++ packages/tasks/tasks/tests/service.spec.ts | 6 ++++++ 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml index 409bc30c12..530e12edae 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.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 -2026-07-26-task-registry-seam.md: b785eb75a632503def10fad583f6a68477cffef6 -2026-07-26-task-registry-seam.zh.md: bfb733a5e1060c9bfe2acc6c4769aa47443d0c9e +2026-07-26-task-registry-seam.md: d550b5b081a7980cceddd3c1eb65c3a9a175906f +2026-07-26-task-registry-seam.zh.md: 1088465b908fd905900aa11479a48632fff3fe6f diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md index b785eb75a6..d550b5b081 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md @@ -32,4 +32,4 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling package implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The seam README states the contract; the implementation README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the seam keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite. -Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package — a boot that loads only `@deepseek-ai/dsh-tasks` gets a pending `ctx.tasks` and producers fail with the standard missing-service behavior rather than a bespoke message. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default. +Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package. `abstract` erases at runtime and this package name used to be the mountable registry, so the seam constructor fails loudly when mounted directly — a stale composition row gets "load an implementation such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default. diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index bfb733a5e1..1088465b90 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -32,4 +32,4 @@ Status: implemented 换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 -代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。 +代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此 seam 的构造函数在被直接挂载时会响亮失败——一条过期的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个方法残缺的 `ctx.tasks` 在远离错误配置处才失败。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。 diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index f4f1e7af7e..b16cb271f1 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -66,4 +66,4 @@ Append-only; new results follow the reusable request prefix. ## Known Limitations and Deferred Work - No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed. -- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface. +- Background mode requires both `@deepseek-ai/dsh-tasks-local` and the model-facing control surface from `@deepseek-ai/dsh-tool-tasks`. diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 17e617e8a7..e237aa0681 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -49,6 +49,13 @@ declare module 'cordis' { */ export abstract class TaskService extends Service { constructor(ctx: Context) { + // `abstract` erases at runtime, and this package name used to be the + // mountable concrete registry — a stale composition row would otherwise + // register a ctx.tasks with no method implementations and fail far from + // the misconfiguration. Fail loud at load instead. + if (new.target === TaskService) { + throw new Error('@deepseek-ai/dsh-tasks is the abstract task registry seam; load an implementation such as @deepseek-ai/dsh-tasks-local instead') + } super(ctx, 'tasks') } diff --git a/packages/tasks/tasks/tests/service.spec.ts b/packages/tasks/tasks/tests/service.spec.ts index d8d582e410..82fc415f51 100644 --- a/packages/tasks/tasks/tests/service.spec.ts +++ b/packages/tasks/tasks/tests/service.spec.ts @@ -79,4 +79,10 @@ describe('TaskService seam', () => { class SecondTaskService extends StubTaskService {} await expect(ctx.plugin(SecondTaskService)).rejects.toThrow(/service "tasks" has been registered/) }) + + it('mounting the abstract seam directly fails loudly at load (stale-composition fence)', async () => { + const ctx = new Context() + await expect(ctx.plugin(TaskService as unknown as typeof StubTaskService)) + .rejects.toThrow(/abstract task registry seam; load an implementation such as @deepseek-ai\/dsh-tasks-local/) + }) }) From 1bc090fe00bc07736925a51505a342194f6b29b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:17:45 +0800 Subject: [PATCH 13/19] fix(tasks): producer diagnostics name the seam, not one implementation Review feedback (tianyicui, PR #657 inline): the missing-service message should mention dsh-tasks, which defines ctx.tasks, rather than promoting a specific backend. The seam's own surfaces (README, the direct-mount fence) keep pointing at implementations, so the pointer chain still lands on dsh-tasks-local without the producer strings going stale when another backend becomes the recommended default. Agent Note updated accordingly (en+zh, re-recorded). --- .../architecture/2026-07-26-task-registry-seam.i18n.yaml | 4 ++-- .../implemented/architecture/2026-07-26-task-registry-seam.md | 4 ++-- .../architecture/2026-07-26-task-registry-seam.zh.md | 4 ++-- packages/bash/tool-bash/README.md | 2 +- packages/bash/tool-bash/src/index.ts | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 2 +- packages/pty/tool-pty/README.md | 2 +- packages/pty/tool-pty/src/index.ts | 2 +- packages/subagent/tool-subagent/src/index.ts | 2 +- packages/subagent/tool-subagent/tests/tool-subagent.spec.ts | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml index 530e12edae..0187c1ff47 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.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 -2026-07-26-task-registry-seam.md: d550b5b081a7980cceddd3c1eb65c3a9a175906f -2026-07-26-task-registry-seam.zh.md: 1088465b908fd905900aa11479a48632fff3fe6f +2026-07-26-task-registry-seam.md: 57ac176cf6d2b0a50fcbcfacd77f6a26b462b582 +2026-07-26-task-registry-seam.zh.md: 252382ac39ebf1e5077fad87fcee2537ae8a9ab3 diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md index d550b5b081..57ac176cf6 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md @@ -16,7 +16,7 @@ The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) s - **`@deepseek-ai/dsh-tasks-local` (implementation)** — `LocalTaskService`, the process-local registry moved verbatim: the in-memory store, per-kind counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, and force-fail teardown. The `dsh-timeout` dependency moves here with it; the seam has no implementation dependencies. - **`@deepseek-ai/dsh-tool-tasks` (consumer)** — unchanged; it injects `'tasks'` and never imports implementation types. -Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks-local` because a deployment fixes them by loading the implementation, not the interface. Producers, `TaskKindMap` declaration merges, and the control surface keep importing `@deepseek-ai/dsh-tasks` only. +Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks` — the seam that defines the absent `ctx.tasks` service — and the seam's own surfaces (its README and the direct-mount fence) point at implementations, so the producer message stays correct when another backend becomes the recommended default. Producers, `TaskKindMap` declaration merges, and the control surface keep importing `@deepseek-ai/dsh-tasks` only. The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` still passes callbacks and exact `Agent` objects, so a durable or cross-process backend still has design work to do before it can implement this interface (identity, restart, ownership, observation). The split moves that future work out of every consumer's dependency graph; it does not pre-design the backend. @@ -32,4 +32,4 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling package implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The seam README states the contract; the implementation README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the seam keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite. -Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package. `abstract` erases at runtime and this package name used to be the mountable registry, so the seam constructor fails loudly when mounted directly — a stale composition row gets "load an implementation such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default. +Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package. `abstract` erases at runtime and this package name used to be the mountable registry, so the seam constructor fails loudly when mounted directly — a stale composition row gets "load an implementation such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration. diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index 1088465b90..252382ac39 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -16,7 +16,7 @@ Status: implemented - **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。 - **`@deepseek-ai/dsh-tool-tasks`(消费方)**——保持不变;它注入 `'tasks'`,从不导入实现类型。 -各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 +各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks`——即定义缺失的 `ctx.tasks` 服务的 seam 包;seam 自身的表面(其 README 与直接挂载防线)会指向各实现,因此当另一个后端日后成为推荐默认时,生产方的消息依旧正确。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 该 seam 保持进程内契约语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能实现此接口之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个消费方的依赖图;它并不预先设计后端。 @@ -32,4 +32,4 @@ Status: implemented 换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 -代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此 seam 的构造函数在被直接挂载时会响亮失败——一条过期的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个方法残缺的 `ctx.tasks` 在远离错误配置处才失败。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。 +代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此 seam 的构造函数在被直接挂载时会响亮失败——一条过期的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个方法残缺的 `ctx.tasks` 在远离错误配置处才失败。 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 0f957e7d89..e58145ee67 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -139,7 +139,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. +Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. #### Token effect diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index b805c7fade..b403c4414e 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -533,7 +533,7 @@ export function apply(ctx: Context, config: Config = {}): void { } const tasks = ctx.get('tasks') if (tasks === undefined) { - throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks') + throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } // The caller owns cancellation until ctx.tasks commits detached ownership. if (exec.signal.aborted) { diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c2b0c3d31b..80840fbf75 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -475,7 +475,7 @@ describe('background execution through the task runtime', () => { const ctx = await setup() // no LocalTaskService / ToolTasks const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks') + expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') }) it('a pre-aborted call is skipped before the process starts', async () => { diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index b16cb271f1..f4f1e7af7e 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -66,4 +66,4 @@ Append-only; new results follow the reusable request prefix. ## Known Limitations and Deferred Work - No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed. -- Background mode requires both `@deepseek-ai/dsh-tasks-local` and the model-facing control surface from `@deepseek-ai/dsh-tool-tasks`. +- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface. diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts index abd0664893..fc66d2646e 100644 --- a/packages/pty/tool-pty/src/index.ts +++ b/packages/pty/tool-pty/src/index.ts @@ -250,7 +250,7 @@ export function apply(ctx: Context, config: Config = {}): void { if (args.run_in_background === true) { if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration') const tasks = ctx.get('tasks') - if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks') + if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') let cancelRequested = false const taskId = tasks.start({ kind: 'pty-send', diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index cd2eb590ae..4eb29d0c6e 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -323,7 +323,7 @@ export function apply(ctx: Context, config: Config): void { } const tasks = ctx.get('tasks') if (tasks === undefined) { - throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks') + throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } // Task preflight finishes before the starter can spawn a child. const id = tasks.start({ diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index d3409e4604..5c27a09e2b 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -680,7 +680,7 @@ describe('dsh-tool-subagent background mode', () => { const ctx = await setup({ provider: 'mock' }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks-local') + expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks') }) it('skips background startup when the tool signal is already aborted', async () => { From ea1d8d06b36788be8407ec439325d525ac30041e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:45:44 +0800 Subject: [PATCH 14/19] test(web): pin every scenario end-state with an aria golden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every spec now commits at least one golden and the interactive ones one per distinct end-state (nine new .expected.md): - live-interactions: cancel.expected.md (frozen partial + 已停止 marker), error-auth.expected.md (the prompt bubble alone — the committed artifact of the web-error-surface gap, the diff that flips when error rendering lands), retry.expected.md (indistinguishable from a clean completion — retries are deliberately invisible in the transcript). - question-composer: answered.expected.md (the question resolved into its tool round trip plus the final reply, takeover gone) beside the existing waiting-state golden. - steering: mid-steer.expected.md pins the accepted-but-INVISIBLE state (the loop drains steering only at the step boundary, so no interjection bubble exists while the question still blocks — if the client ever renders pending steers eagerly, this golden flips first) and settled.expected.md the badged bubble plus obeying reply. - navigation-panes: waterfall.expected.md and details-open.expected.md (tool-name header, Input args, Output result) beside the trajectory one. - lifecycle-chrome: reloaded.expected.md — rendering the same settled transcript from persistence alone IS the recovery claim. Fixture inventories extended to the new closed sets; the Agent Note's expected-outputs policy updated in both languages (per-end-state goldens for interactive scenarios), pairing re-recorded. --- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 14 +++---- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 14 +++---- apps/web/tests/lifecycle-chrome.e2e.ts | 9 ++++- apps/web/tests/live-interactions.e2e.ts | 27 +++++++++++-- apps/web/tests/navigation-panes.e2e.ts | 13 ++++++- apps/web/tests/question-composer.e2e.ts | 9 ++++- .../lifecycle-chrome/reloaded.expected.md | 27 +++++++++++++ .../live-interactions/cancel.expected.md | 24 ++++++++++++ .../live-interactions/error-auth.expected.md | 22 +++++++++++ .../live-interactions/retry.expected.md | 27 +++++++++++++ .../navigation-panes/details-open.expected.md | 3 ++ .../navigation-panes/waterfall.expected.md | 1 + .../question-composer/answered.expected.md | 33 ++++++++++++++++ .../snapshots/steering/mid-steer.expected.md | 39 +++++++++++++++++++ .../snapshots/steering/settled.expected.md | 33 ++++++++++++++++ apps/web/tests/steering.e2e.ts | 30 ++++++++++++-- 17 files changed, 304 insertions(+), 25 deletions(-) create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md create mode 100644 apps/web/tests/snapshots/live-interactions/cancel.expected.md create mode 100644 apps/web/tests/snapshots/live-interactions/error-auth.expected.md create mode 100644 apps/web/tests/snapshots/live-interactions/retry.expected.md create mode 100644 apps/web/tests/snapshots/navigation-panes/details-open.expected.md create mode 100644 apps/web/tests/snapshots/navigation-panes/waterfall.expected.md create mode 100644 apps/web/tests/snapshots/question-composer/answered.expected.md create mode 100644 apps/web/tests/snapshots/steering/mid-steer.expected.md create mode 100644 apps/web/tests/snapshots/steering/settled.expected.md diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index bf6ca9d0d7..3745347bea 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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 -2026-07-24-web-gui-browser-e2e-lane.md: 88730cdecf527ece8033ddab1151afcbc6edd83f -2026-07-24-web-gui-browser-e2e-lane.zh.md: 9850023a49a860a8f4bbdacc8c48fc389ec77210 +2026-07-24-web-gui-browser-e2e-lane.md: cc9b1606a62cfbb2322a4c4647d809dfd809b117 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 3ab0f3716affef6f1446e50d237d74486161afb1 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 88730cdecf..cc9b1606a6 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -32,23 +32,23 @@ Every scenario fails on any pageerror and on the client's connection-loss/gap-re ### Expected outputs -One committed golden per scenario: a normalized `ariaSnapshot()` of the conversation region (`ui.expected.md`) — uuid/cwd/workspace-basename/duration tokens normalized, captured poll-until-equal at the settled milestone — plus a few role/text anchor assertions that stay green under a semantics-preserving component rewrite while the golden churns reviewably. The aria tree is the mechanization of the client rule "assert what the user would see, never class names". World-state assertions ride root-context session events inline (which tool call produced which durable result, whether `turn/end` completed) instead of a second committed log golden: the persisted-log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence, and re-pinning it here would double refresh cost against the tier discipline. `refresh` is the sole golden writer — a missing golden in replay mode fails with the healing command rather than self-bootstrapping. +At least one committed golden per scenario, and one per DISTINCT end-state for the interactive scenarios (cancel/error/retry, waiting/answered, mid-steer/settled, panel-open, post-reload): a normalized `ariaSnapshot()` of the scenario's owning region — uuid/cwd/workspace-basename/duration tokens normalized, captured poll-until-equal at the settled milestone — plus a few role/text anchor assertions that stay green under a semantics-preserving component rewrite while the golden churns reviewably. The aria tree is the mechanization of the client rule "assert what the user would see, never class names". World-state assertions ride root-context session events inline (which tool call produced which durable result, whether `turn/end` completed) instead of a second committed log golden: the persisted-log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence, and re-pinning it here would double refresh cost against the tier discipline. `refresh` is the sole golden writer — a missing golden in replay mode fails with the healing command rather than self-bootstrapping. The typecheck plane split is structural: the three files that boot the host spine (`scaffold`, `replay-round-trip.e2e`, and `seeded-history.e2e`) are excluded from the client-registered `apps/web` project. Those files and their shared `support.ts` are included file-by-file in `tsconfig.host.json` — one program cannot hold both sides of the cordis `Context` merges. ### Modes and fixtures -`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` tokenization; a follow-up keyless refresh regenerates `ui.expected.md`. Every prompting scenario's fixture was recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point with no run-local `rpcId`). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred. +`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` tokenization; a follow-up keyless refresh regenerates the aria goldens. Every prompting scenario's fixture was recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point with no run-local `rpcId`). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred. ### Scenarios 1. **`replay-round-trip`** — new session, prompt through the real composer, replay streams reasoning + a `bash` tool call that really executes in the temp workspace + final text (paced 15ms). Asserts settled markdown, the aria golden, and inline world state (the bash call's durable result is exactly `WEB_E2E_OK\n`, completed `turn/end`, >10 chunk events). 2. **`seeded-history`** — a recorded session seeded cold; the sidebar lists it (group row → session row, collapsed by default), opening renders tool cards and text purely from the log through the implicit cold-resume attach inside `session.history` — zero model calls in replay, so no binding constraints; record mode drives the same turn live (real `read` tool against seeded workspace files) to produce the seed. -3. **`live-interactions`** — one tool-free recorded turn serves three replay-only scenarios through override sidecars whose CONTENT is authored in the spec and minted as a per-run file in a spec-owned temp dir (the derived success entry for the retry append is re-derived from the fixture via `deriveReplayScript`, never copied into a committed sidecar). Cancel: a `{ patches }` `hang` with a `readyFile` marker — the marker's existence proves the stream is parked mid-turn before the test clicks Stop, making mid-stream cancellation deterministic by construction (`turn/end` reason `aborted`, composer re-enabled). AUTH error: a pre-chunk `throw` outside llm-retry's retryable set (`turn/end` reason `error`, zero `llm/retry` events, composer recovers). SERVER retry: `throw` at call 0 + the fixture's own success appended at 1, proving llm-retry end-to-end in the browser via the durable `llm/retry` record (`request/header` logs only on change, so attempt count is invisible there). -4. **`question-composer`** — the shipped composition's resident `ask_user_question` takeover: a recorded turn blocks mid-step on the real userInteraction seam, the composer (`[data-question-key]`) renders in the browser, the test answers through it (the ONE sanctioned place a drive step reacts to model content: the turn cannot complete without the answer, in record and replay alike), and the tool result carries the chosen label. Golden: the composer's stable waiting state. -5. **`steering`** — mid-turn steer while the question composer blocks the step (the deterministic mid-turn window; no timing dependence). The composer locks while running, so the steer POSTs `session.prompt` `mode:'steer'` from the page over the same same-origin `/api` wire the client uses (`TODO(web-steer-composer)`: drive a composer gesture once one exists); everything downstream is product — gateway → `Agent.steer` → step-boundary drain → durable `steering/message` → SSE → badged interjection bubble. Record-mode fixture honesty: the recording is rejected unless the live model's final reply obeys an instruction only the steering message carries. -6. **`navigation-panes`** — one rich two-turn seed (turn 1: bash + two parallel reads in one assistant message; turn 2: a markdown-heavy reply) rendered cold through the seeded-history pattern (zero model calls), serving four surfaces: sidebar search (client-side title filter — asserted only after the durable title lands with the attach baseline, because a cold `SessionSummary` carries no title and search matches the `displayTitle` the user sees; negative query empties the tree, positive narrows, clear restores), the Trajectory tab (turn sections + the step group's tool mix plus the view-area aria golden), the Waterfall tab (span stats + one lane per span — the P-I fold counts a turn-0 prologue span because only assistant/steering nodes carry a turn number, pinned as-is), and the details column (the bash toolview row routes click to openDetails; open/closed is asserted on the frame's `data-details-collapsed` attribute because close collapses the grid column to width 0 without unmounting the subtree). -7. **`lifecycle-chrome`** — one tiny recorded text turn drives three whole-page concerns. Workspace flow over the real wire: the empty-state hero's first send materializes a real Workspace + Session (the jsdom `workspace-flow.snapshot.ts` suite pins this state machine over the fixture client; this scenario pins it through HTTP RPC + SSE + the gateway), proven durably by the session header's cwd being the create-by-name target `/workspace`, plus the hero waiting-state aria golden. Reload recovery: collapse the sidebar (persisted `dsh.layout.panels`), `page.reload`, and the surface comes back whole from persistence alone — layout collapsed, selection restored (`dsh.sessions.current`), the recorded turn re-rendered from `session.history` with zero model calls (the drained replay cursor makes any stray request fail loud at close). Dark mode: no product control flips the theme yet, so the scenario drives the ThemeService's entire DOM contract — the `body[data-ds-dark-theme]` attribute — and pins the shipped cascade: the alias token flips, a painted surface repaints, and removing the attribute restores the light sample exactly (`TODO(web-theme-gesture)` upgrades to a real settings control); per the scope ruling there is no theme/layout golden (aria is color-blind). +3. **`live-interactions`** — one tool-free recorded turn serves three replay-only scenarios through override sidecars whose CONTENT is authored in the spec and minted as a per-run file in a spec-owned temp dir (the derived success entry for the retry append is re-derived from the fixture via `deriveReplayScript`, never copied into a committed sidecar). Cancel: a `{ patches }` `hang` with a `readyFile` marker — the marker's existence proves the stream is parked mid-turn before the test clicks Stop, making mid-stream cancellation deterministic by construction (`turn/end` reason `aborted`, composer re-enabled). AUTH error: a pre-chunk `throw` outside llm-retry's retryable set (`turn/end` reason `error`, zero `llm/retry` events, composer recovers). SERVER retry: `throw` at call 0 + the fixture's own success appended at 1, proving llm-retry end-to-end in the browser via the durable `llm/retry` record (`request/header` logs only on change, so attempt count is invisible there). Each scenario pins its terminal surface as a golden: `cancel.expected.md` (frozen `partial`, 已停止 marker), `error-auth.expected.md` (the prompt bubble alone — the committed artifact of the web-error-surface gap, the diff that flips when error rendering lands), `retry.expected.md` (indistinguishable from a clean completion — retries are deliberately invisible in the transcript). +4. **`question-composer`** — the shipped composition's resident `ask_user_question` takeover: a recorded turn blocks mid-step on the real userInteraction seam, the composer (`[data-question-key]`) renders in the browser, the test answers through it (the ONE sanctioned place a drive step reacts to model content: the turn cannot complete without the answer, in record and replay alike), and the tool result carries the chosen label. Goldens: the composer's stable waiting state (`ui.expected.md`) and the answered transcript (`answered.expected.md` — the question resolved into its tool round trip plus the final reply, takeover gone). +5. **`steering`** — mid-turn steer while the question composer blocks the step (the deterministic mid-turn window; no timing dependence). The composer locks while running, so the steer POSTs `session.prompt` `mode:'steer'` from the page over the same same-origin `/api` wire the client uses (`TODO(web-steer-composer)`: drive a composer gesture once one exists); everything downstream is product — gateway → `Agent.steer` → step-boundary drain → durable `steering/message` → SSE → badged interjection bubble. Record-mode fixture honesty: the recording is rejected unless the live model's final reply obeys an instruction only the steering message carries. Goldens pin the timing semantics visually: `mid-steer.expected.md` captures the accepted-but-invisible state (the loop drains steering only at the step boundary, so no interjection bubble exists while the question still blocks — if the client ever renders pending steers eagerly, this golden flips first) and `settled.expected.md` the badged bubble plus obeying reply. +6. **`navigation-panes`** — one rich two-turn seed (turn 1: bash + two parallel reads in one assistant message; turn 2: a markdown-heavy reply) rendered cold through the seeded-history pattern (zero model calls), serving four surfaces: sidebar search (client-side title filter — asserted only after the durable title lands with the attach baseline, because a cold `SessionSummary` carries no title and search matches the `displayTitle` the user sees; negative query empties the tree, positive narrows, clear restores), the Trajectory tab (turn sections + the step group's tool mix plus the view-area aria golden), the Waterfall tab (span stats + one lane per span — the P-I fold counts a turn-0 prologue span because only assistant/steering nodes carry a turn number, pinned as-is), and the details column (the bash toolview row routes click to openDetails; open/closed is asserted on the frame's `data-details-collapsed` attribute because close collapses the grid column to width 0 without unmounting the subtree). Goldens: `trajectory.expected.md` and `waterfall.expected.md` (each tab's view area) plus `details-open.expected.md` (the open panel: tool-name header, Input args, Output result). +7. **`lifecycle-chrome`** — one tiny recorded text turn drives three whole-page concerns. Workspace flow over the real wire: the empty-state hero's first send materializes a real Workspace + Session (the jsdom `workspace-flow.snapshot.ts` suite pins this state machine over the fixture client; this scenario pins it through HTTP RPC + SSE + the gateway), proven durably by the session header's cwd being the create-by-name target `/workspace`, plus the hero waiting-state aria golden. Reload recovery: collapse the sidebar (persisted `dsh.layout.panels`), `page.reload`, and the surface comes back whole from persistence alone — layout collapsed, selection restored (`dsh.sessions.current`), the recorded turn re-rendered from `session.history` with zero model calls (the drained replay cursor makes any stray request fail loud at close), and `reloaded.expected.md` pins the rebuilt conversation region — rendering the same settled transcript from persistence alone IS the recovery claim. Dark mode: no product control flips the theme yet, so the scenario drives the ThemeService's entire DOM contract — the `body[data-ds-dark-theme]` attribute — and pins the shipped cascade: the alias token flips, a painted surface repaints, and removing the attribute restores the light sample exactly (`TODO(web-theme-gesture)` upgrades to a real settings control); per the scope ruling there is no theme/layout golden (aria is color-blind). ### CI stance diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 9850023a49..3ab0f3716a 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -32,23 +32,23 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 预期输出 -每场景一份提交的预期输出:会话区规范化 `ariaSnapshot()`(`ui.expected.md`)——uuid/cwd/工作区目录名/时长归一为稳定 token,在安定里程碑处轮询至两次相等再采集——外加几条 role/文本锚断言,让保语义的组件重写在预期输出可评审地变动时仍保持绿色锚点。aria 树是 client 规则「断言用户所见,绝不断言类名」的机械化。世界状态断言内联在根上下文的会话事件上(哪次工具调用产生了哪项已持久化的工具结果、`turn/end` 是否完成)而不是第二份提交的日志预期输出:持久化日志表面已由 ACP/headless/TUI 套件经同一循环和持久化钉住,在此重复钉住会违背分层纪律、翻倍刷新成本。`refresh` 是预期输出的唯一写入者——回放模式下预期输出缺失会连同修复命令一起报错,而不是静默自举。 +每场景至少一份提交的预期输出,交互类场景则每个不同终态各一份(取消/错误/重试、等待/已作答、steer 中途/安定、面板打开、重新加载后):该场景所属区域的规范化 `ariaSnapshot()`——uuid/cwd/工作区目录名/时长归一为稳定 token,在安定里程碑处轮询至两次相等再采集——外加几条 role/文本锚断言,让保语义的组件重写在预期输出可评审地变动时仍保持绿色锚点。aria 树是 client 规则「断言用户所见,绝不断言类名」的机械化。世界状态断言内联在根上下文的会话事件上(哪次工具调用产生了哪项已持久化的工具结果、`turn/end` 是否完成)而不是第二份提交的日志预期输出:持久化日志表面已由 ACP/headless/TUI 套件经同一循环和持久化钉住,在此重复钉住会违背分层纪律、翻倍刷新成本。`refresh` 是预期输出的唯一写入者——回放模式下预期输出缺失会连同修复命令一起报错,而不是静默自举。 类型检查平面切分是结构性的:启动 host 主干的三个文件(`scaffold`、`replay-round-trip.e2e` 和 `seeded-history.e2e`)被排除出注册在 client 侧的 `apps/web` 工程。这三个文件及其共享的 `support.ts` 逐文件纳入 `tsconfig.host.json`——一个程序不能同时持有 cordis `Context` 合并的两侧。 ### 模式与 fixture -`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` token 化;随后一次无密钥 refresh 重新生成 `ui.expected.md`。每个发起提示的场景,其 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点,不含当次运行的 `rpcId`)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。 +`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` token 化;随后一次无密钥 refresh 重新生成各份 aria 预期输出。每个发起提示的场景,其 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点,不含当次运行的 `rpcId`)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。 ### 场景 1. **`replay-round-trip`**——新会话,经真实输入框发送提示词,回放流式输出推理(reasoning)+ 一次在临时工作区真实执行的 `bash` 工具调用 + 最终文本(15ms 节奏)。断言安定后的 markdown、aria 预期输出与内联世界状态(这次 bash 调用的已持久化工具结果严格等于 `WEB_E2E_OK\n`、完成的 `turn/end`、>10 个分片事件)。 2. **`seeded-history`**——冷播种一份已录会话;侧栏列出它(分组行 → 会话行,默认折叠),打开后纯凭日志经 `session.history` 内的隐式冷恢复挂载渲染工具卡片与文本——replay 下零模型调用,因此没有任何绑定约束;record 模式实时驱动同一轮(真实 `read` 工具读取播种的工作区文件)来产出种子。 -3. **`live-interactions`**——一段不含工具调用的已录轮次经覆写 sidecar 承载三个仅回放的场景:sidecar 的内容本身写在 spec 里,每次运行时在 spec 自有的临时目录中生成文件(重试追加所用的派生成功条目经 `deriveReplayScript` 从 fixture 重新派生,绝不复制进已提交的 sidecar)。取消:一个带 `readyFile` 标记的 `{ patches }` `hang`——标记文件的存在证明流在测试点击 Stop 之前已停驻在轮次中途,使流中取消按构造即确定(`turn/end` 原因为 `aborted`,输入框重新启用)。AUTH 错误:一次落在 llm-retry 可重试集合之外的分片前 `throw`(`turn/end` 原因为 `error`,零条 `llm/retry` 事件,输入框恢复可用)。SERVER 重试:第 0 次调用 `throw` + 在第 1 次调用处追加 fixture 自身的成功条目,凭持久的 `llm/retry` 记录在浏览器中端到端证明 llm-retry(`request/header` 仅在变化时记录,因此尝试次数在那里不可见)。 -4. **`question-composer`**——已交付组合中常驻的 `ask_user_question` 接管:一段已录轮次在真实的 userInteraction seam 上阻塞于步骤中途,提问输入框(`[data-question-key]`)在浏览器中渲染,测试经它作答(这是驱动步骤对模型内容作出反应的唯一获准之处:没有这个回答,轮次无法完成,record 与 replay 皆然),工具结果携带所选的 label。预期输出:提问输入框稳定的等待态。 -5. **`steering`**——在提问输入框阻塞该步骤时做轮次中途 steering(中途引导),此即确定性的轮次中途窗口,不依赖任何时序。输入框在运行期间锁定,因此这一 steer 由页面经客户端所用的同一条同源 `/api` wire POST `session.prompt` `mode:'steer'`(`TODO(web-steer-composer)`:待有输入框手势后改为驱动它);下游的一切都是产品路径——gateway → `Agent.steer` → 步骤边界排空 → 持久的 `steering/message` → SSE → 带徽标的插话气泡。record 模式的 fixture 诚实性:除非真实模型的最终回复遵循了一条只有 steering 消息才携带的指令,否则该次录制被拒绝。 -6. **`navigation-panes`**——一份内容丰富的双轮次种子(轮次 1:同一条 assistant 消息内的 bash + 两次并行 read;轮次 2:一段 markdown 密集的回复)经 seeded-history 模式冷渲染(零模型调用),承载四个表面:侧栏搜索(客户端标题过滤;仅在持久的标题随 attach 基线一同到达后才断言,因为冷的 `SessionSummary` 不携带标题,而搜索匹配的是用户所见的 `displayTitle`;反例查询清空整棵树,正例查询收窄,清除后复原)、Trajectory 标签页(轮次分节 + 步骤组的工具构成,外加视图区 aria 预期输出)、Waterfall 标签页(span 统计 + 每个 span 一条泳道;只有 assistant/steering 节点携带轮次编号,因此 P-I 折叠会将一个轮次 0 的序幕 span 计入,按原样钉住)与详情列(bash 工具视图行把点击路由到 openDetails;打开/关闭状态断言在 frame 的 `data-details-collapsed` 属性上,因为关闭把网格列收缩到宽度 0 而不卸载子树)。 -7. **`lifecycle-chrome`**——一段极小的已录纯文本轮次驱动三个整页关注点。真实 wire 上的 Workspace 动线:空态 hero 的首次发送物化出真实的 Workspace + Session(jsdom 的 `workspace-flow.snapshot.ts` 套件基于 fixture 客户端钉住这一状态机;本场景则经 HTTP RPC + SSE + gateway 钉住它),其持久证据是会话头部的 cwd 恰为按名创建的目标 `/workspace`,外加 hero 等待态的 aria 预期输出。重新加载恢复:折叠侧栏(持久化于 `dsh.layout.panels`),`page.reload`,整个表面纯凭持久化完整归来——布局保持折叠,选中项恢复(`dsh.sessions.current`),已录轮次从 `session.history` 重新渲染且零模型调用(已耗尽的回放游标使任何离群请求都在 close 时大声失败)。暗色模式:产品尚无任何控件能切换主题,因此本场景驱动 ThemeService 的整个 DOM 契约(即 `body[data-ds-dark-theme]` 属性),并钉住已交付的级联:alias token 翻转,某个实际绘制的表面重绘,移除该属性则精确还原亮色采样值(`TODO(web-theme-gesture)`:待有真实设置控件后升级为驱动它);按范围裁定,主题/布局不设预期输出(aria 感知不到颜色)。 +3. **`live-interactions`**——一段不含工具调用的已录轮次经覆写 sidecar 承载三个仅回放的场景:sidecar 的内容本身写在 spec 里,每次运行时在 spec 自有的临时目录中生成文件(重试追加所用的派生成功条目经 `deriveReplayScript` 从 fixture 重新派生,绝不复制进已提交的 sidecar)。取消:一个带 `readyFile` 标记的 `{ patches }` `hang`——标记文件的存在证明流在测试点击 Stop 之前已停驻在轮次中途,使流中取消按构造即确定(`turn/end` 原因为 `aborted`,输入框重新启用)。AUTH 错误:一次落在 llm-retry 可重试集合之外的分片前 `throw`(`turn/end` 原因为 `error`,零条 `llm/retry` 事件,输入框恢复可用)。SERVER 重试:第 0 次调用 `throw` + 在第 1 次调用处追加 fixture 自身的成功条目,凭持久的 `llm/retry` 记录在浏览器中端到端证明 llm-retry(`request/header` 仅在变化时记录,因此尝试次数在那里不可见)。每个场景都把各自的终态表面钉为一份预期输出:`cancel.expected.md`(冻结的 `partial`、「已停止」标记)、`error-auth.expected.md`(仅有提示词气泡——web-error-surface 缺口的已提交产物,错误渲染落地时翻转的那份 diff)、`retry.expected.md`(与一次干净完成无从区分——重试在文本记录中刻意不可见)。 +4. **`question-composer`**——已交付组合中常驻的 `ask_user_question` 接管:一段已录轮次在真实的 userInteraction seam 上阻塞于步骤中途,提问输入框(`[data-question-key]`)在浏览器中渲染,测试经它作答(这是驱动步骤对模型内容作出反应的唯一获准之处:没有这个回答,轮次无法完成,record 与 replay 皆然),工具结果携带所选的 label。预期输出:提问输入框稳定的等待态(`ui.expected.md`)与已作答的文本记录(`answered.expected.md`——提问已落定为其工具往返加最终回复,接管消失)。 +5. **`steering`**——在提问输入框阻塞该步骤时做轮次中途 steering(中途引导),此即确定性的轮次中途窗口,不依赖任何时序。输入框在运行期间锁定,因此这一 steer 由页面经客户端所用的同一条同源 `/api` wire POST `session.prompt` `mode:'steer'`(`TODO(web-steer-composer)`:待有输入框手势后改为驱动它);下游的一切都是产品路径——gateway → `Agent.steer` → 步骤边界排空 → 持久的 `steering/message` → SSE → 带徽标的插话气泡。record 模式的 fixture 诚实性:除非真实模型的最终回复遵循了一条只有 steering 消息才携带的指令,否则该次录制被拒绝。预期输出以可视方式钉住这一时序语义:`mid-steer.expected.md` 捕捉「已接受但不可见」的状态(循环仅在步骤边界才排空 steering,因此提问仍在阻塞时不存在插话气泡——若 client 日后提前渲染待处理的 steer,这份预期输出会最先翻转),`settled.expected.md` 则捕捉带徽标的气泡加遵循指令的回复。 +6. **`navigation-panes`**——一份内容丰富的双轮次种子(轮次 1:同一条 assistant 消息内的 bash + 两次并行 read;轮次 2:一段 markdown 密集的回复)经 seeded-history 模式冷渲染(零模型调用),承载四个表面:侧栏搜索(客户端标题过滤;仅在持久的标题随 attach 基线一同到达后才断言,因为冷的 `SessionSummary` 不携带标题,而搜索匹配的是用户所见的 `displayTitle`;反例查询清空整棵树,正例查询收窄,清除后复原)、Trajectory 标签页(轮次分节 + 步骤组的工具构成,外加视图区 aria 预期输出)、Waterfall 标签页(span 统计 + 每个 span 一条泳道;只有 assistant/steering 节点携带轮次编号,因此 P-I 折叠会将一个轮次 0 的序幕 span 计入,按原样钉住)与详情列(bash 工具视图行把点击路由到 openDetails;打开/关闭状态断言在 frame 的 `data-details-collapsed` 属性上,因为关闭把网格列收缩到宽度 0 而不卸载子树)。预期输出:`trajectory.expected.md` 与 `waterfall.expected.md`(各自标签页的视图区),外加 `details-open.expected.md`(打开的面板:工具名标题、Input 参数、Output 结果)。 +7. **`lifecycle-chrome`**——一段极小的已录纯文本轮次驱动三个整页关注点。真实 wire 上的 Workspace 动线:空态 hero 的首次发送物化出真实的 Workspace + Session(jsdom 的 `workspace-flow.snapshot.ts` 套件基于 fixture 客户端钉住这一状态机;本场景则经 HTTP RPC + SSE + gateway 钉住它),其持久证据是会话头部的 cwd 恰为按名创建的目标 `/workspace`,外加 hero 等待态的 aria 预期输出。重新加载恢复:折叠侧栏(持久化于 `dsh.layout.panels`),`page.reload`,整个表面纯凭持久化完整归来——布局保持折叠,选中项恢复(`dsh.sessions.current`),已录轮次从 `session.history` 重新渲染且零模型调用(已耗尽的回放游标使任何离群请求都在 close 时大声失败),且 `reloaded.expected.md` 钉住重建后的会话区——纯凭持久化渲染出同一份安定的文本记录,这本身就是恢复主张。暗色模式:产品尚无任何控件能切换主题,因此本场景驱动 ThemeService 的整个 DOM 契约(即 `body[data-ds-dark-theme]` 属性),并钉住已交付的级联:alias token 翻转,某个实际绘制的表面重绘,移除该属性则精确还原亮色采样值(`TODO(web-theme-gesture)`:待有真实设置控件后升级为驱动它);按范围裁定,主题/布局不设预期输出(aria 感知不到颜色)。 ### CI 立场 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 2ab4f5aeea..d91704f585 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -25,6 +25,9 @@ import { 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.' @@ -112,6 +115,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () // 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) @@ -147,6 +154,6 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md']) + 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 index 632dc79085..60dec690c7 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -20,13 +20,20 @@ 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, fixtureUserPrompts, launchWebScaffold, recordFixture, - watchConsole, webSnapshotMode, type WebScaffold, + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { 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 @@ -123,6 +130,10 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // Composer recovered; no streaming node lingers. 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 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([]) }, 120_000) @@ -144,6 +155,10 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // "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([]) }, 120_000) @@ -167,10 +182,16 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // 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([]) }, 120_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) + 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 index 2147ef9cdd..bbae7363df 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -24,6 +24,8 @@ 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' @@ -148,6 +150,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { 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 () => { @@ -167,6 +172,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // 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) @@ -174,6 +183,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { 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']) + 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 index 9678a7a648..361cd72be6 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -23,6 +23,9 @@ import { 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() 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 options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.' @@ -90,10 +93,14 @@ describe('web e2e: resident question composer round trip', () => { // 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([]) }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md']) }) }) 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..6c0b20cc22 --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -0,0 +1,27 @@ +- 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 "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/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md new file mode 100644 index 0000000000..1c0807b33b --- /dev/null +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -0,0 +1,24 @@ +- 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 "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/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md new file mode 100644 index 0000000000..5862e97ab6 --- /dev/null +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -0,0 +1,22 @@ +- 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 "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/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md new file mode 100644 index 0000000000..ed77fac08b --- /dev/null +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -0,0 +1,27 @@ +- 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 "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/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md new file mode 100644 index 0000000000..39bf528542 --- /dev/null +++ b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md @@ -0,0 +1,3 @@ +- text: bash +- button "关闭详情" +- text: "Input { \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" } Output NAVIGATION_OK" 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..c0e64f7bf3 --- /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 options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop. +- button "Think The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that.": + - img + - text: Think The user wants me to use the ask_user_question tool to ask a specific question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". 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\"}, {\"label\": \"Green\"}]}]}" +- button "Think The user answered \"Blue\". I need to reply with the single word DONE and stop.": + - img + - text: Think The user answered "Blue". I need to reply with the single word DONE and stop. +- paragraph: DONE +- text: cache hit 99% · 15,978 tokens · 1 turns · 2 steps +- 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/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/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md new file mode 100644 index 0000000000..6faa2f01a3 --- /dev/null +++ b/apps/web/tests/snapshots/steering/settled.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 "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 "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/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index e4617e2a04..e3ed1ddac8 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -20,13 +20,22 @@ 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, fixtureUserPrompts, launchWebScaffold, recordFixture, - watchConsole, webSnapshotMode, type WebScaffold, + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { 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.' @@ -104,6 +113,17 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { }, { 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. @@ -137,10 +157,14 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { 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([]) }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md']) }) }) From f1b7d52a778ab68fd1bfd2da044c9fb6cf328fb1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:12:52 +0800 Subject: [PATCH 15/19] fix review findings: hostile code accessor + swallowed teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ds-review-bot findings were real: - markLlmAdapterFailure's carried-facts cross-check read error.code directly; a foreign Error with a valid own failure payload but a throwing code accessor would replace the original adapter error with the accessor exception, breaking the error-identity guarantee. The read now goes through foreignErrorCode(), which contains the trap and falls back to the normalized snapshot (test: hostile code accessor beside a valid failure payload -> original identity kept, UNKNOWN facts). - live-interactions' afterEach caught scaffold.close() into undefined, silently disabling ReplayHandle.assertConsumed() — the fixture-drift tripwire — and hiding cleanup defects. Teardown now runs every step, collects failures, and rethrows (AggregateError when several). --- apps/web/tests/live-interactions.e2e.ts | 13 ++++++++++--- packages/llm/llm/src/adapter-failure.ts | 13 ++++++++++++- packages/llm/llm/tests/service.spec.ts | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 60dec690c7..46a03281f9 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -57,12 +57,19 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { let sidecarDir: string | undefined afterEach(async () => { - await browser?.close().catch(() => undefined) + // 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 - await scaffold?.close().catch(() => undefined) + const closing = scaffold scaffold = undefined - if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch(() => 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. */ diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index 8da17807fa..2cf2dbe216 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -53,7 +53,7 @@ export function markLlmAdapterFailure( // exactly when class identity is lost (a second copy of this package in // the process, e.g. a source-plane test harness over a lib-plane boot). const carried = ownFailureSnapshot(error) - const failure = carried !== undefined && carried.code === error.code ? carried : Object.freeze({ + const failure = carried !== undefined && carried.code === foreignErrorCode(error) ? carried : Object.freeze({ message: errorMessage(error), code: harnessErrorCode(error), }) @@ -61,6 +61,17 @@ export function markLlmAdapterFailure( return error } +/** Read a foreign error's `code` for the cross-check without letting an SDK accessor replace the primary failure. */ +function foreignErrorCode(error: Error & { code?: string }): unknown { + try { + return error.code + } catch (_sdkCodeGetter) { + // An unreadable code cannot confirm the carried facts describe this + // error; the caller falls back to the normalized snapshot. + return undefined + } +} + /** Snapshot an own data property without invoking an SDK-defined accessor. */ function ownFailureSnapshot(error: Error): LlmFailure | undefined { try { diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 90be1ffcb0..7c9f632a20 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -324,6 +324,27 @@ describe('LlmService', () => { expect(llmFailureOf(stream, original)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' }) }) + it('keeps an SDK Error exact when a valid failure payload rides a hostile code accessor', async () => { + // The carried-facts cross-check reads error.code; a throwing accessor + // there must fall back to the normalized snapshot instead of replacing + // the original adapter error with the accessor exception. + const original = Object.assign(new Error('busy'), { + failure: { message: 'busy', code: 'SERVER', status: 503 }, + }) + Object.defineProperty(original, 'code', { + get() { throw new Error('SDK code accessor must not escape') }, + }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' }) + }) + it('falls back safely when SDK objects trap failure inspection or expose malformed facts', async () => { const propertyTrap = new Proxy(new HarnessError('descriptor trapped', 'SERVER'), { getOwnPropertyDescriptor(target, property) { From 19989156306d7b78240b9ddba61da795c5df3fbc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:40:54 +0800 Subject: [PATCH 16/19] test(web): refresh the hero golden for the localized settings label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The websettings merge (#644) localized the sidebar foot to 设置; the lifecycle-chrome hero golden pinned the old English label. Keyless DSH_SNAPSHOT=refresh rewrite; full lane green twice after. --- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 55317addcb..407e1c7c5a 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -12,9 +12,9 @@ - img - textbox "Search name, keywords..." - tree "Sessions": No sessions yet -- button "Settings": +- button "设置": - img - - text: Settings + - text: 设置 - text: Let's start building - button "Choose workspace": - img From f8342b0a8e1ce85497a97a26325ac1c6904dbd1f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:38:36 +0800 Subject: [PATCH 17/19] test(web): cover the settings surface and workspace management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new keyless scenarios for the functionality master gained since this lane's base (#644 websettings, #643 workspace browser rework), both zero model calls: - settings-chrome: the modal shell (sidebar-foot trigger aria states, role=dialog, aria-current section switch to the deliberately empty Models, Escape + close-button paths, dialog aria golden); the Appearance row as the REAL theme gesture — retiring lifecycle-chrome's TODO(web-theme-gesture): clicking 深色 runs aria-pressed -> persisted dsh.theme -> body[data-ds-dark-theme] -> alias-token flip, survives reload, and 'system' follows the emulated OS scheme both ways; the Language row switches the settings-scoped copy to English (dsh.locale persisted, survives reload) and restores zh. Intentional reloads tear the SSE stream, so the spec drains exactly its own reconnect warnings — the tripwire still fails on unexpected connection loss. - workspace-management: create-by-name twice through the region-header dialog (host-durable via ctx.workspace.list()); rename end to end — hover-revealed row menu (the button is display:none until the row hovers), duplicate-name pre-check (inline role=alert + disabled primary before any wire call), then workspace.rename through the real RPC, row update, host durability, reload survival; the flat 'In one list' view (section label flips, group headers drop, dsh.workspace.view persists across reload, grouped restored); the session hover card (dwell to open, closes on pointer leave). The one session row reuses seeded-history's committed seed — no new recording. Deliberately not driven: the inert menu rows and drag reorder (deferred in the note with re-entry triggers). Agent Note gains scenarios 8-9 and the drag-reorder deferred item in both languages; llm-replay README's zh side catches up with the { patches } paragraph; pairings re-recorded. --- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 5 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 5 +- apps/web/tests/lifecycle-chrome.e2e.ts | 9 +- apps/web/tests/settings-chrome.e2e.ts | 179 ++++++++++++++++++ .../settings-chrome/dialog.expected.md | 30 +++ .../snapshots/workspace-management/.gitkeep | 0 apps/web/tests/workspace-management.e2e.ts | 169 +++++++++++++++++ apps/web/tsconfig.json | 2 + packages/support/llm-replay/README.i18n.yaml | 4 +- packages/support/llm-replay/README.zh.md | 2 +- tsconfig.host.json | 2 + 12 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 apps/web/tests/settings-chrome.e2e.ts create mode 100644 apps/web/tests/snapshots/settings-chrome/dialog.expected.md create mode 100644 apps/web/tests/snapshots/workspace-management/.gitkeep create mode 100644 apps/web/tests/workspace-management.e2e.ts diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 3745347bea..3600a981c9 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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 -2026-07-24-web-gui-browser-e2e-lane.md: cc9b1606a62cfbb2322a4c4647d809dfd809b117 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 3ab0f3716affef6f1446e50d237d74486161afb1 +2026-07-24-web-gui-browser-e2e-lane.md: 1d96028e8e9255518b4e5127f0aeeaa4ee68b411 +2026-07-24-web-gui-browser-e2e-lane.zh.md: e07fce4b62c05b1b4774e6d1758321e3b7bd315c diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index cc9b1606a6..1d96028e8e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -48,7 +48,9 @@ The typecheck plane split is structural: the three files that boot the host spin 4. **`question-composer`** — the shipped composition's resident `ask_user_question` takeover: a recorded turn blocks mid-step on the real userInteraction seam, the composer (`[data-question-key]`) renders in the browser, the test answers through it (the ONE sanctioned place a drive step reacts to model content: the turn cannot complete without the answer, in record and replay alike), and the tool result carries the chosen label. Goldens: the composer's stable waiting state (`ui.expected.md`) and the answered transcript (`answered.expected.md` — the question resolved into its tool round trip plus the final reply, takeover gone). 5. **`steering`** — mid-turn steer while the question composer blocks the step (the deterministic mid-turn window; no timing dependence). The composer locks while running, so the steer POSTs `session.prompt` `mode:'steer'` from the page over the same same-origin `/api` wire the client uses (`TODO(web-steer-composer)`: drive a composer gesture once one exists); everything downstream is product — gateway → `Agent.steer` → step-boundary drain → durable `steering/message` → SSE → badged interjection bubble. Record-mode fixture honesty: the recording is rejected unless the live model's final reply obeys an instruction only the steering message carries. Goldens pin the timing semantics visually: `mid-steer.expected.md` captures the accepted-but-invisible state (the loop drains steering only at the step boundary, so no interjection bubble exists while the question still blocks — if the client ever renders pending steers eagerly, this golden flips first) and `settled.expected.md` the badged bubble plus obeying reply. 6. **`navigation-panes`** — one rich two-turn seed (turn 1: bash + two parallel reads in one assistant message; turn 2: a markdown-heavy reply) rendered cold through the seeded-history pattern (zero model calls), serving four surfaces: sidebar search (client-side title filter — asserted only after the durable title lands with the attach baseline, because a cold `SessionSummary` carries no title and search matches the `displayTitle` the user sees; negative query empties the tree, positive narrows, clear restores), the Trajectory tab (turn sections + the step group's tool mix plus the view-area aria golden), the Waterfall tab (span stats + one lane per span — the P-I fold counts a turn-0 prologue span because only assistant/steering nodes carry a turn number, pinned as-is), and the details column (the bash toolview row routes click to openDetails; open/closed is asserted on the frame's `data-details-collapsed` attribute because close collapses the grid column to width 0 without unmounting the subtree). Goldens: `trajectory.expected.md` and `waterfall.expected.md` (each tab's view area) plus `details-open.expected.md` (the open panel: tool-name header, Input args, Output result). -7. **`lifecycle-chrome`** — one tiny recorded text turn drives three whole-page concerns. Workspace flow over the real wire: the empty-state hero's first send materializes a real Workspace + Session (the jsdom `workspace-flow.snapshot.ts` suite pins this state machine over the fixture client; this scenario pins it through HTTP RPC + SSE + the gateway), proven durably by the session header's cwd being the create-by-name target `/workspace`, plus the hero waiting-state aria golden. Reload recovery: collapse the sidebar (persisted `dsh.layout.panels`), `page.reload`, and the surface comes back whole from persistence alone — layout collapsed, selection restored (`dsh.sessions.current`), the recorded turn re-rendered from `session.history` with zero model calls (the drained replay cursor makes any stray request fail loud at close), and `reloaded.expected.md` pins the rebuilt conversation region — rendering the same settled transcript from persistence alone IS the recovery claim. Dark mode: no product control flips the theme yet, so the scenario drives the ThemeService's entire DOM contract — the `body[data-ds-dark-theme]` attribute — and pins the shipped cascade: the alias token flips, a painted surface repaints, and removing the attribute restores the light sample exactly (`TODO(web-theme-gesture)` upgrades to a real settings control); per the scope ruling there is no theme/layout golden (aria is color-blind). +7. **`lifecycle-chrome`** — one tiny recorded text turn drives three whole-page concerns. Workspace flow over the real wire: the empty-state hero's first send materializes a real Workspace + Session (the jsdom `workspace-flow.snapshot.ts` suite pins this state machine over the fixture client; this scenario pins it through HTTP RPC + SSE + the gateway), proven durably by the session header's cwd being the create-by-name target `/workspace`, plus the hero waiting-state aria golden. Reload recovery: collapse the sidebar (persisted `dsh.layout.panels`), `page.reload`, and the surface comes back whole from persistence alone — layout collapsed, selection restored (`dsh.sessions.current`), the recorded turn re-rendered from `session.history` with zero model calls (the drained replay cursor makes any stray request fail loud at close), and `reloaded.expected.md` pins the rebuilt conversation region — rendering the same settled transcript from persistence alone IS the recovery claim. Dark mode: the scenario drives the ThemeService's DOM contract seam directly — the `body[data-ds-dark-theme]` attribute — and pins the shipped cascade (alias token flips, a painted surface repaints, removal restores the light sample exactly), independent of the settings surface whose real user gesture `settings-chrome` owns; per the scope ruling there is no theme/layout golden (aria is color-blind). +8. **`settings-chrome`** — the settings surface (#644), zero model calls on a blank frame. The modal shell: sidebar-foot trigger (`aria-haspopup`/`aria-expanded`) opens `role=dialog` 设置, General active by default with the skeleton rows plus the functional Language and Appearance rows (dialog aria golden), section switch moves `aria-current` to the deliberately empty Models, closes via Escape and the header close button. The Appearance row is the REAL theme gesture (retiring the lifecycle scenario's `TODO(web-theme-gesture)`): clicking 深色 runs the whole chain — `aria-pressed`, persisted `dsh.theme`, `body[data-ds-dark-theme]`, alias-token flip — and survives reload; `system` follows the emulated OS scheme both ways (`page.emulateMedia`), and the spec restores the light default for inter-spec hygiene. The Language row switches the settings-scoped copy to English (`dsh.locale` persisted, dialog re-registers as Settings/General/Appearance), survives reload, and restores zh — only the settings namespaces are localized today, so the scenario asserts exactly that surface. Intentional reloads tear the SSE stream, so the spec drains exactly the reconnect warnings its own reloads caused; the tripwire still fails on any unexpected connection loss. +9. **`workspace-management`** — the workspace browser operations (#643), zero model calls (workspace.create/rename are host RPCs; the one session row comes from re-seeding seeded-history's committed seed, so no new fixture is recorded). Create-by-name twice through the region-header + dialog (`workspace.create` mkdirs and prepends to the durable registry — asserted host-side via `ctx.workspace.list()`). Rename end to end: the hover-revealed row-actions menu (the button is `display:none` until its row hovers) → Rename dialog → the duplicate-name pre-check raises the inline `role=alert` and disables the primary button before any wire call → a fresh name goes through the `workspace.rename` RPC, updates the row, persists on the host, and survives reload. The flat "In one list" view: the Group by menu flips the section label to Sessions, drops group headers (seeded session becomes a top-level row), persists in `dsh.workspace.view` across reload, and the spec restores grouped mode. The session hover card renders after the dwell (display-only, no aria role — text anchors) and closes when the pointer leaves. Deliberately NOT driven: the visual-only menu rows this iteration ships inert (session Rename/Fork/Delete, workspace Delete) and drag reorder — see Deferred. ### CI stance @@ -91,6 +93,7 @@ The lane itself: `pnpm run test:web` runs every scenario keylessly alongside the - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. - **Web error surface**: the client consumes no `agent/error` frames and a pre-chunk failure freezes no partial, so a non-retryable provider failure renders no error copy — the user sees the send simply stop. The AUTH scenario pins the current contract (no crash, composer recovers, turn logged `error`) and `FIXME(web-error-surface)` marks where visible error text gets asserted once the UI grows an error rendering. - **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one. +- **Drag session reorder**: `workspace.insertSessionBefore` (manual ordering, #643) has no browser scenario yet — it needs two sessions materialized in ONE workspace (a two-script recorded fixture) plus synthesized HTML5 drag events; add it when that surface changes or regresses. The inert menu rows (session Rename/Fork/Delete, workspace Delete) get scenarios when they gain behavior. ## Consequences diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 3ab0f3716a..e07fce4b62 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -48,7 +48,9 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 4. **`question-composer`**——已交付组合中常驻的 `ask_user_question` 接管:一段已录轮次在真实的 userInteraction seam 上阻塞于步骤中途,提问输入框(`[data-question-key]`)在浏览器中渲染,测试经它作答(这是驱动步骤对模型内容作出反应的唯一获准之处:没有这个回答,轮次无法完成,record 与 replay 皆然),工具结果携带所选的 label。预期输出:提问输入框稳定的等待态(`ui.expected.md`)与已作答的文本记录(`answered.expected.md`——提问已落定为其工具往返加最终回复,接管消失)。 5. **`steering`**——在提问输入框阻塞该步骤时做轮次中途 steering(中途引导),此即确定性的轮次中途窗口,不依赖任何时序。输入框在运行期间锁定,因此这一 steer 由页面经客户端所用的同一条同源 `/api` wire POST `session.prompt` `mode:'steer'`(`TODO(web-steer-composer)`:待有输入框手势后改为驱动它);下游的一切都是产品路径——gateway → `Agent.steer` → 步骤边界排空 → 持久的 `steering/message` → SSE → 带徽标的插话气泡。record 模式的 fixture 诚实性:除非真实模型的最终回复遵循了一条只有 steering 消息才携带的指令,否则该次录制被拒绝。预期输出以可视方式钉住这一时序语义:`mid-steer.expected.md` 捕捉「已接受但不可见」的状态(循环仅在步骤边界才排空 steering,因此提问仍在阻塞时不存在插话气泡——若 client 日后提前渲染待处理的 steer,这份预期输出会最先翻转),`settled.expected.md` 则捕捉带徽标的气泡加遵循指令的回复。 6. **`navigation-panes`**——一份内容丰富的双轮次种子(轮次 1:同一条 assistant 消息内的 bash + 两次并行 read;轮次 2:一段 markdown 密集的回复)经 seeded-history 模式冷渲染(零模型调用),承载四个表面:侧栏搜索(客户端标题过滤;仅在持久的标题随 attach 基线一同到达后才断言,因为冷的 `SessionSummary` 不携带标题,而搜索匹配的是用户所见的 `displayTitle`;反例查询清空整棵树,正例查询收窄,清除后复原)、Trajectory 标签页(轮次分节 + 步骤组的工具构成,外加视图区 aria 预期输出)、Waterfall 标签页(span 统计 + 每个 span 一条泳道;只有 assistant/steering 节点携带轮次编号,因此 P-I 折叠会将一个轮次 0 的序幕 span 计入,按原样钉住)与详情列(bash 工具视图行把点击路由到 openDetails;打开/关闭状态断言在 frame 的 `data-details-collapsed` 属性上,因为关闭把网格列收缩到宽度 0 而不卸载子树)。预期输出:`trajectory.expected.md` 与 `waterfall.expected.md`(各自标签页的视图区),外加 `details-open.expected.md`(打开的面板:工具名标题、Input 参数、Output 结果)。 -7. **`lifecycle-chrome`**——一段极小的已录纯文本轮次驱动三个整页关注点。真实 wire 上的 Workspace 动线:空态 hero 的首次发送物化出真实的 Workspace + Session(jsdom 的 `workspace-flow.snapshot.ts` 套件基于 fixture 客户端钉住这一状态机;本场景则经 HTTP RPC + SSE + gateway 钉住它),其持久证据是会话头部的 cwd 恰为按名创建的目标 `/workspace`,外加 hero 等待态的 aria 预期输出。重新加载恢复:折叠侧栏(持久化于 `dsh.layout.panels`),`page.reload`,整个表面纯凭持久化完整归来——布局保持折叠,选中项恢复(`dsh.sessions.current`),已录轮次从 `session.history` 重新渲染且零模型调用(已耗尽的回放游标使任何离群请求都在 close 时大声失败),且 `reloaded.expected.md` 钉住重建后的会话区——纯凭持久化渲染出同一份安定的文本记录,这本身就是恢复主张。暗色模式:产品尚无任何控件能切换主题,因此本场景驱动 ThemeService 的整个 DOM 契约(即 `body[data-ds-dark-theme]` 属性),并钉住已交付的级联:alias token 翻转,某个实际绘制的表面重绘,移除该属性则精确还原亮色采样值(`TODO(web-theme-gesture)`:待有真实设置控件后升级为驱动它);按范围裁定,主题/布局不设预期输出(aria 感知不到颜色)。 +7. **`lifecycle-chrome`**——一段极小的已录纯文本轮次驱动三个整页关注点。真实 wire 上的 Workspace 动线:空态 hero 的首次发送物化出真实的 Workspace + Session(jsdom 的 `workspace-flow.snapshot.ts` 套件基于 fixture 客户端钉住这一状态机;本场景则经 HTTP RPC + SSE + gateway 钉住它),其持久证据是会话头部的 cwd 恰为按名创建的目标 `/workspace`,外加 hero 等待态的 aria 预期输出。重新加载恢复:折叠侧栏(持久化于 `dsh.layout.panels`),`page.reload`,整个表面纯凭持久化完整归来——布局保持折叠,选中项恢复(`dsh.sessions.current`),已录轮次从 `session.history` 重新渲染且零模型调用(已耗尽的回放游标使任何离群请求都在 close 时大声失败),且 `reloaded.expected.md` 钉住重建后的会话区——纯凭持久化渲染出同一份安定的文本记录,这本身就是恢复主张。暗色模式:本场景直接驱动 ThemeService 的 DOM 契约 seam(即 `body[data-ds-dark-theme]` 属性),并钉住已交付的级联(alias token 翻转,某个实际绘制的表面重绘,移除该属性则精确还原亮色采样值),且独立于设置表面——该表面的真实用户手势归 `settings-chrome` 管;按范围裁定,主题/布局不设预期输出(aria 感知不到颜色)。 +8. **`settings-chrome`**——设置表面(#644),空白 frame 上零模型调用。模态框外壳:侧栏底部的触发按钮(`aria-haspopup`/`aria-expanded`)打开 `role=dialog` 的「设置」,默认激活「通用设置」,其中既有骨架行,也有具备实际功能的「语言」与「外观」两行(对话框 aria 预期输出);分节切换把 `aria-current` 移到刻意留空的「模型」分节;经 Escape 与头部的「关闭」按钮均可关闭。「外观」行是真正的主题手势(lifecycle 场景的 `TODO(web-theme-gesture)` 就此撤除):点击「深色」跑通整条链路(`aria-pressed`、持久化的 `dsh.theme`、`body[data-ds-dark-theme]`、alias token 翻转)并在重新加载后存续;`system` 双向跟随所模拟的操作系统配色方案(`page.emulateMedia`),该 spec 还会恢复「浅色」默认值以保证 spec 之间互不污染。「语言」行把设置范围内的文案切换为 English(`dsh.locale` 持久化,对话框重新注册为 Settings/General/Appearance),在重新加载后存续,最后恢复为「中文」——目前本地化只覆盖设置命名空间,因此该场景断言的恰是这一表面。有意的重新加载会撕断 SSE 流,因此该 spec 恰好只排空自身重新加载引发的重连警告;任何意外的连接丢失仍会触发绊线失败。 +9. **`workspace-management`**——工作区浏览器操作(#643),零模型调用(workspace.create/rename 是 host 侧 RPC;唯一的会话行来自重新播种 seeded-history 已提交的种子,因此没有录制任何新 fixture)。经区域头部的「+」对话框按名创建两次(`workspace.create` 会 mkdir 并把新项前插到持久注册表——host 侧经 `ctx.workspace.list()` 断言)。端到端的重命名:悬停显露的行操作菜单(按钮在所在行悬停之前是 `display:none`)→ Rename 对话框 → 重名预检在发出任何 wire 调用之前就亮出内联 `role=alert` 并禁用主按钮 → 换一个全新名称则走 `workspace.rename` RPC,更新该行、在 host 上持久化并在重新加载后存续。扁平的「In one list」视图:Group by 菜单把分节标签翻转为 Sessions,去掉分组头(播种的会话成为顶层行),在 `dsh.workspace.view` 中持久化并跨重新加载存续,该 spec 最后恢复分组模式。会话悬停卡片在驻留延时后渲染(纯展示,无 aria role——用文本锚定),指针移开即关闭。刻意不驱动:本次迭代以无行为形态交付的纯视觉菜单行(会话的 Rename/Fork/Delete、工作区的 Delete)与拖拽重排——见「暂缓」。 ### CI 立场 @@ -91,6 +93,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 - **Web 错误表面**:客户端不消费任何 `agent/error` 帧,分片前的失败也没有可冻结的部分输出,因此不可重试的提供方失败不渲染任何错误文案——用户看到的只是发送就此停住。AUTH 场景钉住当前契约(不崩溃、输入框恢复可用、轮次记录为 `error`),`FIXME(web-error-surface)` 标记了待 UI 长出错误渲染后断言可见错误文本的位置。 - **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer;`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 +- **拖拽会话重排**:`workspace.insertSessionBefore`(手动排序,#643)尚无浏览器场景——它需要在同一个工作区里物化两个会话(一份双脚本的已录 fixture)外加合成的 HTML5 拖拽事件;当该表面变更或回归时再补充。无行为的菜单行(会话的 Rename/Fork/Delete、工作区的 Delete)待长出行为后获得各自的场景。 ## 后果 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index d91704f585..5b16736771 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -124,10 +124,11 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark')) - // No product control flips the theme yet — the ThemeService's whole DOM - // contract is the body[data-ds-dark-theme] attribute, so the scenario - // drives exactly that seam and pins the shipped stylesheet's cascade. - // TODO(web-theme-gesture): drive a real settings control once one exists. + // 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 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts new file mode 100644 index 0000000000..1d3c0d52bb --- /dev/null +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -0,0 +1,179 @@ +// 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 { + 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) + + /** + * An INTENTIONAL reload tears the SSE stream mid-flight, so the dying + * page's reconnect note is expected — drain exactly those entries so the + * tripwire still fails the spec on any UNEXPECTED connection loss. + */ + const drainReloadWarnings = (): void => { + const kept = tripwire.warnings.filter(text => !/connection lost/i.test(text)) + tripwire.warnings.length = 0 + tripwire.warnings.push(...kept) + } + + 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). + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + drainReloadWarnings() + 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. + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + drainReloadWarnings() + 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/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/workspace-management/.gitkeep b/apps/web/tests/snapshots/workspace-management/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts new file mode 100644 index 0000000000..9aa857f731 --- /dev/null +++ b/apps/web/tests/workspace-management.e2e.ts @@ -0,0 +1,169 @@ +// 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, 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 { + 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 + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // 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) + + /** + * An INTENTIONAL reload tears the SSE stream mid-flight, so the dying + * page's reconnect note is expected — drain exactly those entries so the + * tripwire still fails the spec on any UNEXPECTED connection loss. + */ + const drainReloadWarnings = (): void => { + const kept = tripwire.warnings.filter(text => !/connection lost/i.test(text)) + tripwire.warnings.length = 0 + tripwire.warnings.push(...kept) + } + + 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() + // The pick menu's Create workspace submenu opens on hover/focus. + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + 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') + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + drainReloadWarnings() + await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + 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. + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + drainReloadWarnings() + 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). + await page.getByText('Ungrouped', { exact: true }).click() + // A cold summary carries no durable title, so the row falls back to a + // cwd-derived display title — anchored on the run-local workspace-root + // basename rather than a literal. + const wsBase = scaffold.workspaceCwd.split('/').pop()! + const sessionRow = page.locator('[role="treeitem"]').filter({ hasText: wsBase }).first() + 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 55ad95ffdb..b22b6f1efa 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -28,6 +28,8 @@ "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" ], diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 63b9979098..9039715a71 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/README.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 -README.md: 901a3b7b4312fffd93e6d375c378e39064318260 -README.zh.md: b9a8068d329e28933c934e7ad352ac65641f3d23 +README.md: f184e271ff9e68760db43cfe79d4f39be81ef00f +README.zh.md: a47bc81ab747fcdc130d535e116979e45304b319 diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index b9a8068d32..a47bc81ab7 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -10,7 +10,7 @@ Fixture 就是持久化会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件携带每个 `StreamChunk`,因此按 `(turn, step)` 对其分组可重建每次 `stream()` 调用的分片序列(每个 loop 步骤一次模型调用)。因此,录制操作是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件不执行录制。Fixture 的 `request/header` 内容可能被 token 化为 `{{system}}`/`{{tools}}`(harness 在一个场景中固定该内容,并擦除其余场景);回放对此并不关心,因为派生只读取 `assistant/chunk` 事件和第 0 行会话 header。 -有两种失败 mode 无法仅从 `assistant/chunk` 重建:在任何分片前纯抛出(例如 HTTP 401,日志只包含 `turn/end {error}` 而没有分片),以及 cancel/hang(是时序,而非分片内容)。需要这些的场景提供可选 sidecar(`/replay.override.json`:一个 `ReplayEntry[]`),以替换派生脚本。`hang` 条目可以指定 `readyFile`;在其前缀分片到达 loop 后、等待取消前,回放会写入该空标记,使外部驱动器可以在不观察展示更新的情况下确定性取消。 +有两种失败 mode 无法仅从 `assistant/chunk` 重建:在任何分片前纯抛出(例如 HTTP 401,日志只包含 `turn/end {error}` 而没有分片),以及 cancel/hang(是时序,而非分片内容)。需要这些的场景提供可选 sidecar(`/replay.override.json`),它要么替换派生脚本(裸 `ReplayEntry[]`),要么增补派生脚本(`{ patches: [{ at, entry }] }`:保留全部由 JSONL 派生的调用,仅在点名的调用索引处换入,索引从 0 计;`at` 等于派生长度时为追加,正是注入的瞬态抛出之后那次重试尝试所占的槽位)。`hang` 条目可以指定 `readyFile`;在其前缀分片到达 loop 后、等待取消前,回放会写入该空标记,使外部驱动器可以在不观察展示更新的情况下确定性取消。 ## 嵌套 agent:每会话键控 diff --git a/tsconfig.host.json b/tsconfig.host.json index 63f1c835b9..72b7f245c3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -15,6 +15,8 @@ "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", + "apps/web/tests/settings-chrome.e2e.ts", + "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/cli/tests/**/*.ts", From d0aebc9f9270f30fd91666ed4c21f895cb4e4da1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:59:05 +0800 Subject: [PATCH 18/19] docs(tasks): bring the zh side of the tasks pairs along after the master merge Master made bilingual pairing mandatory repo-wide; this PR's seam-split edits to the tasks docs get their zh counterparts: a new pair for the dsh-tasks-local README and minimal updates to the tasks core-data doc, agent-spine-demo README, and the tasks family READMEs, with pairing records re-recorded. --- docs/core-data-structures/tasks.i18n.yaml | 4 +-- docs/core-data-structures/tasks.zh.md | 2 +- .../agent-spine-demo/README.i18n.yaml | 4 +-- .../examples/agent-spine-demo/README.zh.md | 2 +- packages/tasks/README.i18n.yaml | 4 +-- packages/tasks/README.zh.md | 5 ++-- packages/tasks/tasks-local/README.i18n.yaml | 6 +++++ packages/tasks/tasks-local/README.md | 2 ++ packages/tasks/tasks-local/README.zh.md | 26 +++++++++++++++++++ packages/tasks/tasks/README.i18n.yaml | 4 +-- packages/tasks/tasks/README.zh.md | 16 ++++-------- 11 files changed, 52 insertions(+), 23 deletions(-) create mode 100644 packages/tasks/tasks-local/README.i18n.yaml create mode 100644 packages/tasks/tasks-local/README.zh.md diff --git a/docs/core-data-structures/tasks.i18n.yaml b/docs/core-data-structures/tasks.i18n.yaml index f9d14f2163..3a5a45566b 100644 --- a/docs/core-data-structures/tasks.i18n.yaml +++ b/docs/core-data-structures/tasks.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 -tasks.md: d1f5a6d7b369e6113132f60e493cf87757e20599 -tasks.zh.md: 1562d9401f0f55ac6d6260902b8b1c71d9664d48 +tasks.md: a38055d3ef7aa18e62678f92eb5ac5ae2a09c205 +tasks.zh.md: b5dd7f75c7df3e359bc995fce57f1ca2dc7fd017 diff --git a/docs/core-data-structures/tasks.zh.md b/docs/core-data-structures/tasks.zh.md index 1562d9401f..b5dd7f75c7 100644 --- a/docs/core-data-structures/tasks.zh.md +++ b/docs/core-data-structures/tasks.zh.md @@ -151,4 +151,4 @@ interface TaskRead { ## 服务行为 -[`TaskService`](../../packages/tasks/tasks/src/index.ts) 提供原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。包(package)契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam 定义原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部实现。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。seam 契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index fe005dcae8..aaf3b492cd 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.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 -README.md: 736de2ea01e1524854c57f91d128b82a9fe0c9e8 -README.zh.md: 4ffe47ba82539d12c9b74b1690392d58d21a24b1 +README.md: 32874bf2839c194572ddde8c4ed007297f763ccc +README.zh.md: 57a06a00203b8e67f2f33c87d7450d1a0789d7e6 diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 4ffe47ba82..57a06a0020 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -24,7 +24,7 @@ @deepseek-ai/dsh-tool-goal optional model-facing goal controls @deepseek-ai/dsh-goal-session optional same-session goal-round driver @deepseek-ai/dsh-llm-retry bounded transient request retry policy -@deepseek-ai/dsh-tasks generic background-task registry +@deepseek-ai/dsh-tasks-local generic background-task registry @deepseek-ai/dsh-invariants configurable invariant registry service @deepseek-ai/dsh-session/invariant @deepseek-ai/dsh-agent/invariant diff --git a/packages/tasks/README.i18n.yaml b/packages/tasks/README.i18n.yaml index 0cd358369b..79f5e7b8e2 100644 --- a/packages/tasks/README.i18n.yaml +++ b/packages/tasks/README.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 -README.md: f1c224345c94a833c44cbafb635be7617e8c42bf -README.zh.md: 610a84a1506b4bb780297322f7827e6f04533bc1 +README.md: 9bafe5633bb7e57a5404ffb41fad04b621832b6d +README.zh.md: 73c87a2c95ccebf70558a2051149eca4ba41f60e diff --git a/packages/tasks/README.zh.md b/packages/tasks/README.zh.md index 610a84a150..73c87a2c95 100644 --- a/packages/tasks/README.zh.md +++ b/packages/tasks/README.zh.md @@ -2,11 +2,12 @@ [English](README.md) | 中文 -后台 task id、拥有者隔离、读取、取消、等待和完成通知的共用归属位置。Bash、subagent 及未来的长时间运行工具共用一套面向模型的协议。参见[后台任务运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)。 +后台 task id、拥有者隔离、读取、取消、等待和完成通知的共用归属位置。Bash、subagent 及未来的长时间运行工具共用一套面向模型的协议。参见[后台任务运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和[任务注册表 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md)。 | 包(package) | ctx 键 | 角色 | |---|---|---| -| [`tasks`](tasks/README.md)(`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | 注册表服务:品牌化 `-N` id、按拥有者设防的 read/kill/wait/list、结算记账、等待完成的拥有者清理路径,以及防止 `attachSurface` 配置错误的防线 | +| [`tasks`](tasks/README.md)(`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | 注册表 seam:品牌化 `-N` id、按拥有者设防的 read/kill/wait/list 契约、快照词汇、防止 `attachSurface` 配置错误的防线,以及快照不变式配套插件 | +| [`tasks-local`](tasks-local/README.md)(`@deepseek-ai/dsh-tasks-local`) | 无 | 进程局部的注册表实现:内存记录、首次结果优先的结算簿记,以及等待完成的拥有者清理与拆卸路径 | | [`tool-tasks`](tool-tasks/README.md)(`@deepseek-ai/dsh-tool-tasks`) | 无 | 面向模型的控制接口:`task_output`、`task_list`、`task_kill`、完成通知注入和后台工作习惯提示词段落 | 注册表拥有跨生产方或接口重载的状态;工具包拥有呈现。生产方通过 `ctx.tasks.start` 注册执行钩子,并自行决定其配置是否公开 `run_in_background`。 diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml new file mode 100644 index 0000000000..532331c5be --- /dev/null +++ b/packages/tasks/tasks-local/README.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 +README.md: 23ca6fca61ccb59c855e5d6da6b0a2e23e7cb632 +README.zh.md: c5553a76690278f5b6d5ec40a55d213ef7e1e2d9 diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md index 5f57d3409d..23ca6fca61 100644 --- a/packages/tasks/tasks-local/README.md +++ b/packages/tasks/tasks-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-tasks-local +English | [中文](README.zh.md) + Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry seam: `LocalTaskService` keeps every record in memory, issues per-kind `-N` ids, and hands out fresh snapshots, never live state. It has no config; load it as a plugin and it registers as `ctx.tasks`. ## Lifecycle diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md new file mode 100644 index 0000000000..c5553a7669 --- /dev/null +++ b/packages/tasks/tasks-local/README.zh.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-tasks-local + +[English](README.md) | 中文 + +[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表 seam 的进程局部实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。它没有配置;作为插件加载后即注册为 `ctx.tasks`。 + +## 生命周期 + +任务属于其 owner 和后端,而不是生产方工具 fiber,因此重载生产方或表层不会停止任务。某个 owner 的第一个任务会把一个受等待的 effect 附加到精确的 `Agent` scope。owner 释放会取消该对象的任务,等待生产方完全停稳,并移除其快照;复用 agent 或 Session id 无法重定向旧清理。 + +服务释放会关闭监听器、取消所有存活任务、等待其记录,并从仍存活的 owner scope 分离 effect。如果拆卸取消抛出异常,服务会强制把记录标为失败,并警告工作可能遗留,而不会死锁。取消已返回但始终不终止 `done` 时,系统无法将其与缓慢停止区分开,拆卸可能因此停滞。 + +结算遵循首次结果优先:最早出现的终止结果(生产方结算、被隔离为 `failed` 的 `done` 拒绝,或拆卸强制失败)只记录一次,只通知监听器一次并对每个监听器单独隔离故障,然后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此呈现完成情况的表层不会重复发出通知。 + +## 模型体验 + +通过生产方插件和 [`dsh-tool-tasks`](../tool-tasks/README.md) 间接影响;它们会渲染 task id、输出、状态、取消和完成通知。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由命名消费方负责。 + +## 已知限制与暂缓事项 + +- **任务只存在于进程本地**:记录随 harness 进程一起消亡;持久或跨重启执行需要一个单独实现该 seam 的后端。 +- **静默无效的取消可能使拆卸停滞**:只有显式抛出异常才能安全地强制标为失败。 diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index fc9157bddc..b86c63e859 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/README.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 -README.md: 1a073add0fde8f2e519cc83b087af6a531a6cbb8 -README.zh.md: 795602701f072068f05bbf16ee98bdeea57548af +README.md: 2f822bad139020f0ebae0165aa4e8893853f635d +README.zh.md: 4adb249f31241d5c61c3f8cbee638e8243e4a92e diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index 795602701f..4adb249f31 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -进程局部的后台任务注册表(`ctx.tasks`)。它为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理。生产方插件使用其不透明 id namespace 扩展 `TaskKindMap`。 +后台任务注册表 seam(`ctx.tasks`)。抽象的 `TaskService` 及其词汇类型在同一份契约下为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理;进程局部注册表位于 [`dsh-tasks-local`](../tasks-local/README.md)。生产方插件使用其不透明 id namespace 扩展 `TaskKindMap`。 -## 服务 API +## 服务契约 - `start(spec): TaskId` 验证控制表层、spec、精确的存活 owner,以及可选的正 `outputLimitBytes`,然后只调用生产方的 `run()` 一次。启动方抛出异常时不注册任何内容;成功返回会直接提交,不再执行其他可能失败的步骤。 - `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。 @@ -18,13 +18,9 @@ `outputLimitBytes` 是生产方拥有的模型呈现策略,会原样携带到快照中。控制表层在添加状态或通知元数据后应用它;注册表不会重写生产方输出,也不会为省略此字段的生产方虚构默认值。 -## 生命周期 +实现还必须兑现契约的生命周期语义:注册的存续期长于生产方与控制表层的 fiber,owner 释放和服务释放会取消存活工作并等待守约的生产方,结算遵循首次结果优先(一条终止记录、一轮故障隔离的监听器通知,然后释放等待方)。 -任务属于其 owner 和后端,而不是生产方工具 fiber,因此重载生产方或表层不会停止任务。某个 owner 的第一个任务会把一个受等待的 effect 附加到精确的 `Agent` scope。owner 释放会取消该对象的任务,等待生产方完全停稳,并移除其快照;复用 agent 或 Session id 无法重定向旧清理。 - -服务释放会关闭监听器、取消所有存活任务、等待其记录,并从仍存活的 owner scope 分离 effect。如果拆卸取消抛出异常,服务会强制把记录标为失败,并警告工作可能遗留,而不会死锁。取消已返回但始终不终止 `done` 时,系统无法将其与缓慢停止区分开,拆卸可能因此停滞。 - -参见[任务类型目录](../../../docs/core-data-structures/tasks.md)和[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)。 +参见[任务类型目录](../../../docs/core-data-structures/tasks.md)、[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md)。 ## 模型体验 @@ -36,8 +32,6 @@ ## 已知限制与暂缓事项 -- **任务只存在于进程本地**:持久或跨重启执行需要独立生命周期。 -- **服务与实现没有拆分**:第二个后端必须先定义塑造该边界的生命周期。 - **流输出只有一个消费游标**:独立观察者需要游标或快照 API。 - **前台工作无法提升**:生产方在启动前选择前台或后台。 -- **静默无效的取消可能使拆卸停滞**:只有显式抛出异常才能安全地强制标为失败。 +- **契约是进程内的**:`TaskStart.run()` 传入回调和确切的 `Agent` 对象;持久或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 From e2882f486baff86aa455ac1f65396960e04bab6d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:38:33 +0800 Subject: [PATCH 19/19] fix(review): harden web replay verification Validate replay sidecars and cross-copy failure facts, make browser console tripwires and macOS temp paths deterministic, and wait for asynchronous TUI resume details. Keep the owning docs, translations, and generated catalog aligned. --- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 28 ++--- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 28 ++--- apps/web/tests/lifecycle-chrome.e2e.ts | 5 +- apps/web/tests/live-interactions.e2e.ts | 3 + apps/web/tests/question-composer.e2e.ts | 5 +- apps/web/tests/scaffold.ts | 18 ++- apps/web/tests/settings-chrome.e2e.ts | 19 +-- apps/web/tests/steering.e2e.ts | 1 + apps/web/tests/workspace-management.e2e.ts | 19 +-- docs/config-catalog.md | 2 +- packages/llm/llm/src/adapter-failure.ts | 20 ++- packages/llm/llm/tests/service.spec.ts | 73 ++++++++++- packages/support/acp-snapshot/src/suite.ts | 13 +- packages/support/llm-replay/README.i18n.yaml | 4 +- packages/support/llm-replay/README.md | 10 +- packages/support/llm-replay/README.zh.md | 10 +- packages/support/llm-replay/src/index.ts | 119 ++++++++++++++++-- .../llm-replay/tests/llm-replay.spec.ts | 51 ++++++-- packages/ui/tui/tests/tui.spec.ts | 10 +- 20 files changed, 310 insertions(+), 132 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 3600a981c9..ff72d8e9ee 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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 -2026-07-24-web-gui-browser-e2e-lane.md: 1d96028e8e9255518b4e5127f0aeeaa4ee68b411 -2026-07-24-web-gui-browser-e2e-lane.zh.md: e07fce4b62c05b1b4774e6d1758321e3b7bd315c +2026-07-24-web-gui-browser-e2e-lane.md: c4e34b3f44162c7021cb25681eea7e49ac78f672 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 466e1c0fc16aac21b87b68cfedaec4fb22a417e2 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 1d96028e8e..c4e34b3f44 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -10,7 +10,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin ## Decision -`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replayed through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, asserting a normalized conversation aria golden plus in-process world state. No new package; the product deltas are additive `dsh-llm-replay` surfaces (`paceMs`, `ReplayHandle`, and the `{ patches }` override form: indexed augmentation over the derived script so a sidecar expresses "call N throws / hangs, everything else replays as recorded" without copying recorded chunks), one `dsh-llm` fix the retry scenario exposed (a carried `failure` snapshot is honored on any Error — the `instanceof` gate dropped provider codes across dual package copies, source-plane replay over a lib-plane boot), and the `llm-retry` row the web composition was missing. +`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replay through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, with normalized aria goldens for user-visible states and in-process assertions for durable world state. The supporting product contracts are `dsh-llm-replay` pacing, consumption checks, and validated indexed override patches; cross-package `dsh-llm` failures retain validated provider facts through own data properties; and the shipped web composition mounts `llm-retry` for transient model failures. ### Scaffold: `apps/web/tests/scaffold.ts` @@ -32,25 +32,17 @@ Every scenario fails on any pageerror and on the client's connection-loss/gap-re ### Expected outputs -At least one committed golden per scenario, and one per DISTINCT end-state for the interactive scenarios (cancel/error/retry, waiting/answered, mid-steer/settled, panel-open, post-reload): a normalized `ariaSnapshot()` of the scenario's owning region — uuid/cwd/workspace-basename/duration tokens normalized, captured poll-until-equal at the settled milestone — plus a few role/text anchor assertions that stay green under a semantics-preserving component rewrite while the golden churns reviewably. The aria tree is the mechanization of the client rule "assert what the user would see, never class names". World-state assertions ride root-context session events inline (which tool call produced which durable result, whether `turn/end` completed) instead of a second committed log golden: the persisted-log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence, and re-pinning it here would double refresh cost against the tier discipline. `refresh` is the sole golden writer — a missing golden in replay mode fails with the healing command rather than self-bootstrapping. +Scenarios with a stable owning region commit a normalized `ariaSnapshot()` for each distinct user-visible state; cross-region workspace-management states instead use semantic DOM assertions plus authoritative host-state checks. UUID, cwd, workspace basename, and duration volatility collapse to stable tokens; captures poll until consecutive normalized reads agree. Role and text anchors remain semantic guards around the reviewable goldens and own cross-region states directly. World-state assertions use root-context session events rather than a second committed log golden because the ACP, headless, and TUI suites already pin the persisted-log surface through the same loop and persistence. `refresh` is the sole golden writer; a missing replay golden fails with the regeneration command. -The typecheck plane split is structural: the three files that boot the host spine (`scaffold`, `replay-round-trip.e2e`, and `seeded-history.e2e`) are excluded from the client-registered `apps/web` project. Those files and their shared `support.ts` are included file-by-file in `tsconfig.host.json` — one program cannot hold both sides of the cordis `Context` merges. +The typecheck plane split is structural: the host scaffold, its support module, and every web spec that boots or inspects the host composition are excluded from the client-registered `apps/web` project and included file-by-file in `tsconfig.host.json`. One program cannot hold both sides of the Cordis `Context` merges. ### Modes and fixtures -`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` tokenization; a follow-up keyless refresh regenerates the aria goldens. Every prompting scenario's fixture was recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point with no run-local `rpcId`). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred. +`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless). Prompting specs separate drive steps shared by all modes from replay/refresh assertions; record mode drives the live composer, harvests the in-memory session header and events, scrubs request headers, and tokenizes run-local session, cwd, and RPC identities. A follow-up keyless refresh regenerates aria goldens. Each prompt is checked against its fixture's recorded `user/message`, and each scenario directory has a closed inventory whose JSONL files are scrub fixed points. Web fixtures scrub headers everywhere and pin no header class; see Deferred. -### Scenarios +### Coverage contract -1. **`replay-round-trip`** — new session, prompt through the real composer, replay streams reasoning + a `bash` tool call that really executes in the temp workspace + final text (paced 15ms). Asserts settled markdown, the aria golden, and inline world state (the bash call's durable result is exactly `WEB_E2E_OK\n`, completed `turn/end`, >10 chunk events). -2. **`seeded-history`** — a recorded session seeded cold; the sidebar lists it (group row → session row, collapsed by default), opening renders tool cards and text purely from the log through the implicit cold-resume attach inside `session.history` — zero model calls in replay, so no binding constraints; record mode drives the same turn live (real `read` tool against seeded workspace files) to produce the seed. -3. **`live-interactions`** — one tool-free recorded turn serves three replay-only scenarios through override sidecars whose CONTENT is authored in the spec and minted as a per-run file in a spec-owned temp dir (the derived success entry for the retry append is re-derived from the fixture via `deriveReplayScript`, never copied into a committed sidecar). Cancel: a `{ patches }` `hang` with a `readyFile` marker — the marker's existence proves the stream is parked mid-turn before the test clicks Stop, making mid-stream cancellation deterministic by construction (`turn/end` reason `aborted`, composer re-enabled). AUTH error: a pre-chunk `throw` outside llm-retry's retryable set (`turn/end` reason `error`, zero `llm/retry` events, composer recovers). SERVER retry: `throw` at call 0 + the fixture's own success appended at 1, proving llm-retry end-to-end in the browser via the durable `llm/retry` record (`request/header` logs only on change, so attempt count is invisible there). Each scenario pins its terminal surface as a golden: `cancel.expected.md` (frozen `partial`, 已停止 marker), `error-auth.expected.md` (the prompt bubble alone — the committed artifact of the web-error-surface gap, the diff that flips when error rendering lands), `retry.expected.md` (indistinguishable from a clean completion — retries are deliberately invisible in the transcript). -4. **`question-composer`** — the shipped composition's resident `ask_user_question` takeover: a recorded turn blocks mid-step on the real userInteraction seam, the composer (`[data-question-key]`) renders in the browser, the test answers through it (the ONE sanctioned place a drive step reacts to model content: the turn cannot complete without the answer, in record and replay alike), and the tool result carries the chosen label. Goldens: the composer's stable waiting state (`ui.expected.md`) and the answered transcript (`answered.expected.md` — the question resolved into its tool round trip plus the final reply, takeover gone). -5. **`steering`** — mid-turn steer while the question composer blocks the step (the deterministic mid-turn window; no timing dependence). The composer locks while running, so the steer POSTs `session.prompt` `mode:'steer'` from the page over the same same-origin `/api` wire the client uses (`TODO(web-steer-composer)`: drive a composer gesture once one exists); everything downstream is product — gateway → `Agent.steer` → step-boundary drain → durable `steering/message` → SSE → badged interjection bubble. Record-mode fixture honesty: the recording is rejected unless the live model's final reply obeys an instruction only the steering message carries. Goldens pin the timing semantics visually: `mid-steer.expected.md` captures the accepted-but-invisible state (the loop drains steering only at the step boundary, so no interjection bubble exists while the question still blocks — if the client ever renders pending steers eagerly, this golden flips first) and `settled.expected.md` the badged bubble plus obeying reply. -6. **`navigation-panes`** — one rich two-turn seed (turn 1: bash + two parallel reads in one assistant message; turn 2: a markdown-heavy reply) rendered cold through the seeded-history pattern (zero model calls), serving four surfaces: sidebar search (client-side title filter — asserted only after the durable title lands with the attach baseline, because a cold `SessionSummary` carries no title and search matches the `displayTitle` the user sees; negative query empties the tree, positive narrows, clear restores), the Trajectory tab (turn sections + the step group's tool mix plus the view-area aria golden), the Waterfall tab (span stats + one lane per span — the P-I fold counts a turn-0 prologue span because only assistant/steering nodes carry a turn number, pinned as-is), and the details column (the bash toolview row routes click to openDetails; open/closed is asserted on the frame's `data-details-collapsed` attribute because close collapses the grid column to width 0 without unmounting the subtree). Goldens: `trajectory.expected.md` and `waterfall.expected.md` (each tab's view area) plus `details-open.expected.md` (the open panel: tool-name header, Input args, Output result). -7. **`lifecycle-chrome`** — one tiny recorded text turn drives three whole-page concerns. Workspace flow over the real wire: the empty-state hero's first send materializes a real Workspace + Session (the jsdom `workspace-flow.snapshot.ts` suite pins this state machine over the fixture client; this scenario pins it through HTTP RPC + SSE + the gateway), proven durably by the session header's cwd being the create-by-name target `/workspace`, plus the hero waiting-state aria golden. Reload recovery: collapse the sidebar (persisted `dsh.layout.panels`), `page.reload`, and the surface comes back whole from persistence alone — layout collapsed, selection restored (`dsh.sessions.current`), the recorded turn re-rendered from `session.history` with zero model calls (the drained replay cursor makes any stray request fail loud at close), and `reloaded.expected.md` pins the rebuilt conversation region — rendering the same settled transcript from persistence alone IS the recovery claim. Dark mode: the scenario drives the ThemeService's DOM contract seam directly — the `body[data-ds-dark-theme]` attribute — and pins the shipped cascade (alias token flips, a painted surface repaints, removal restores the light sample exactly), independent of the settings surface whose real user gesture `settings-chrome` owns; per the scope ruling there is no theme/layout golden (aria is color-blind). -8. **`settings-chrome`** — the settings surface (#644), zero model calls on a blank frame. The modal shell: sidebar-foot trigger (`aria-haspopup`/`aria-expanded`) opens `role=dialog` 设置, General active by default with the skeleton rows plus the functional Language and Appearance rows (dialog aria golden), section switch moves `aria-current` to the deliberately empty Models, closes via Escape and the header close button. The Appearance row is the REAL theme gesture (retiring the lifecycle scenario's `TODO(web-theme-gesture)`): clicking 深色 runs the whole chain — `aria-pressed`, persisted `dsh.theme`, `body[data-ds-dark-theme]`, alias-token flip — and survives reload; `system` follows the emulated OS scheme both ways (`page.emulateMedia`), and the spec restores the light default for inter-spec hygiene. The Language row switches the settings-scoped copy to English (`dsh.locale` persisted, dialog re-registers as Settings/General/Appearance), survives reload, and restores zh — only the settings namespaces are localized today, so the scenario asserts exactly that surface. Intentional reloads tear the SSE stream, so the spec drains exactly the reconnect warnings its own reloads caused; the tripwire still fails on any unexpected connection loss. -9. **`workspace-management`** — the workspace browser operations (#643), zero model calls (workspace.create/rename are host RPCs; the one session row comes from re-seeding seeded-history's committed seed, so no new fixture is recorded). Create-by-name twice through the region-header + dialog (`workspace.create` mkdirs and prepends to the durable registry — asserted host-side via `ctx.workspace.list()`). Rename end to end: the hover-revealed row-actions menu (the button is `display:none` until its row hovers) → Rename dialog → the duplicate-name pre-check raises the inline `role=alert` and disables the primary button before any wire call → a fresh name goes through the `workspace.rename` RPC, updates the row, persists on the host, and survives reload. The flat "In one list" view: the Group by menu flips the section label to Sessions, drops group headers (seeded session becomes a top-level row), persists in `dsh.workspace.view` across reload, and the spec restores grouped mode. The session hover card renders after the dwell (display-only, no aria role — text anchors) and closes when the pointer leaves. Deliberately NOT driven: the visual-only menu rows this iteration ships inert (session Rename/Fork/Delete, workspace Delete) and drag reorder — see Deferred. +The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout persistence, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. ### CI stance @@ -70,7 +62,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **Placeholder `DEEPSEEK_API_KEY` + replay interception instead of disabling the adapter row.** Rejected despite zero composition change and two in-tree precedents: it satisfies `llm-deepseek`'s fail-loud key check with a lie and leaves a dead adapter mounted-but-intercepted; the disabled row (the ACP overlay's move) is honest keylessness and fails loud at the earliest resolvable point. -**A `packages/support/web-snapshot` package with a `defineWebSnapshotSuite` factory.** Rejected: chromium-driving source cannot honestly hold per-file 100% coverage on browserless coverage runners, and at two scenarios a factory generalizes from one consumer while the genuinely shared logic is already exported from gated packages. Re-entry trigger: a second web-shaped consumer or ≥6 scenarios with demonstrably drifting inline branches; the package boundary would then be drawn browser-free. +**A `packages/support/web-snapshot` package with a `defineWebSnapshotSuite` factory.** Rejected: chromium-driving source cannot honestly hold per-file 100% coverage on browserless coverage runners, and the scenario-specific interactions have not produced a stable browser-free contract beyond the helpers already exported from gated packages and the local scaffold. Reconsider when a second web-shaped consumer or demonstrably repeated lifecycle code establishes that contract. **A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on root-context events keep the world-verification duty. @@ -80,11 +72,11 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key W5 smoke stays as the live-model complement. -**A client `data-dsh-busy` settled signal.** Deferred: the multi-condition settled polls proved sufficient at two scenarios and the host-side `whenIdle` barrier does the heavy lifting. Re-entry trigger: the first settled-poll flake, or a scenario needing a state the DOM does not expose. +**A client `data-dsh-busy` settled signal.** Deferred: the host-side `whenIdle` barrier plus stable DOM polls cover the current scenarios. Reconsider after the first settled-poll flake or when a required state is not observable in the DOM. ## Testing -The lane itself: `pnpm run test:web` runs every scenario keylessly alongside the existing smoke pair; `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` re-records a scenario's fixture against the live model; `DSH_SNAPSHOT=refresh` rewrites the aria goldens keylessly. `paceMs` validation, pacing floor, abort-during-pace, both `assertConsumed` failure shapes, and the `{ patches }` acceptance/rejection paths (index swap keeps siblings, `at == length` appends, out-of-range/non-integer loud) are pinned in `packages/support/llm-replay/tests/llm-replay.spec.ts`. +`pnpm run test:web` runs the lane keylessly. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh` rewrites aria goldens keylessly. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. ## Deferred @@ -93,7 +85,7 @@ The lane itself: `pnpm run test:web` runs every scenario keylessly alongside the - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. - **Web error surface**: the client consumes no `agent/error` frames and a pre-chunk failure freezes no partial, so a non-retryable provider failure renders no error copy — the user sees the send simply stop. The AUTH scenario pins the current contract (no crash, composer recovers, turn logged `error`) and `FIXME(web-error-surface)` marks where visible error text gets asserted once the UI grows an error rendering. - **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one. -- **Drag session reorder**: `workspace.insertSessionBefore` (manual ordering, #643) has no browser scenario yet — it needs two sessions materialized in ONE workspace (a two-script recorded fixture) plus synthesized HTML5 drag events; add it when that surface changes or regresses. The inert menu rows (session Rename/Fork/Delete, workspace Delete) get scenarios when they gain behavior. +- **Drag session reorder**: `workspace.insertSessionBefore` has no browser scenario; it needs two sessions materialized in one workspace plus synthesized HTML5 drag events. Add it when that surface changes or regresses. The inert session Rename/Fork/Delete and workspace Delete menu rows get scenarios when they gain behavior. ## Consequences diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index e07fce4b62..466e1c0fc1 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -10,7 +10,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## 决策 -`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放,断言规范化后的会话区 aria 预期输出加进程内世界状态。不新增包(package);产品侧增量为 `dsh-llm-replay` 的增量接口(`paceMs`、`ReplayHandle`,以及 `{ patches }` 覆写形式:对派生脚本按索引增补,使一份 sidecar 无需复制已录分片即可表达「第 N 次调用抛错/挂起,其余照录回放」),一处由重试场景暴露的 `dsh-llm` 修复(携带的 `failure` 快照对任何 Error 都生效——此前的 `instanceof` 判定会在两份包副本并存时丢弃提供方错误码,即源码平面回放叠在 lib 平面 boot 之上的情形),以及 web 组合此前缺失的 `llm-retry` 行。 +`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久世界状态则使用进程内断言。配套的产品契约包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。 ### Scaffold:`apps/web/tests/scaffold.ts` @@ -32,25 +32,17 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 预期输出 -每场景至少一份提交的预期输出,交互类场景则每个不同终态各一份(取消/错误/重试、等待/已作答、steer 中途/安定、面板打开、重新加载后):该场景所属区域的规范化 `ariaSnapshot()`——uuid/cwd/工作区目录名/时长归一为稳定 token,在安定里程碑处轮询至两次相等再采集——外加几条 role/文本锚断言,让保语义的组件重写在预期输出可评审地变动时仍保持绿色锚点。aria 树是 client 规则「断言用户所见,绝不断言类名」的机械化。世界状态断言内联在根上下文的会话事件上(哪次工具调用产生了哪项已持久化的工具结果、`turn/end` 是否完成)而不是第二份提交的日志预期输出:持久化日志表面已由 ACP/headless/TUI 套件经同一循环和持久化钉住,在此重复钉住会违背分层纪律、翻倍刷新成本。`refresh` 是预期输出的唯一写入者——回放模式下预期输出缺失会连同修复命令一起报错,而不是静默自举。 +具有稳定所属区域的场景会为每个不同的用户可见状态提交一份规范化的 `ariaSnapshot()`;跨区域的工作区管理状态则使用语义 DOM 断言和权威的 host 状态检查。UUID、cwd、工作区目录名与时长等易变内容会归一为稳定 token;采集过程持续轮询,直到连续两次规范化读取结果相同。Role 与文本锚点继续充当可评审预期输出周围的语义防线,并直接覆盖跨区域状态。世界状态断言使用根上下文的会话事件,而不是第二份提交的日志预期输出,因为 ACP、headless 与 TUI 套件已经通过同一循环和持久化钉住持久化日志表面。`refresh` 是预期输出的唯一写入者;回放模式下缺少预期输出时,测试会连同重新生成命令一起失败。 -类型检查平面切分是结构性的:启动 host 主干的三个文件(`scaffold`、`replay-round-trip.e2e` 和 `seeded-history.e2e`)被排除出注册在 client 侧的 `apps/web` 工程。这三个文件及其共享的 `support.ts` 逐文件纳入 `tsconfig.host.json`——一个程序不能同时持有 cordis `Context` 合并的两侧。 +类型检查平面切分是结构性的:host scaffold、其支持模块,以及每个启动或检查 host 组合的 web spec 都会从注册在 client 侧的 `apps/web` 工程中排除,并逐文件纳入 `tsconfig.host.json`。一个程序不能同时持有 Cordis `Context` 合并的两侧。 ### 模式与 fixture -`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` token 化;随后一次无密钥 refresh 重新生成各份 aria 预期输出。每个发起提示的场景,其 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点,不含当次运行的 `rpcId`)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。 +`DSH_SNAPSHOT` 选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)。发起提示的 spec 将所有模式共用的驱动步骤与仅供 replay/refresh 使用的断言分开;record 模式驱动真实输入框,采收内存中的会话 header 与事件,脱敏请求头,并 token 化当次运行的会话、cwd 与 RPC 标识。随后一次无密钥 refresh 重新生成 aria 预期输出。每条提示词都会与 fixture 中录制的 `user/message` 核对;每个场景目录都采用封闭清单,其中每个 JSONL 都是脱敏不动点。Web fixture 全部脱敏请求头且不钉任何 header 类别;见「暂缓」。 -### 场景 +### 覆盖契约 -1. **`replay-round-trip`**——新会话,经真实输入框发送提示词,回放流式输出推理(reasoning)+ 一次在临时工作区真实执行的 `bash` 工具调用 + 最终文本(15ms 节奏)。断言安定后的 markdown、aria 预期输出与内联世界状态(这次 bash 调用的已持久化工具结果严格等于 `WEB_E2E_OK\n`、完成的 `turn/end`、>10 个分片事件)。 -2. **`seeded-history`**——冷播种一份已录会话;侧栏列出它(分组行 → 会话行,默认折叠),打开后纯凭日志经 `session.history` 内的隐式冷恢复挂载渲染工具卡片与文本——replay 下零模型调用,因此没有任何绑定约束;record 模式实时驱动同一轮(真实 `read` 工具读取播种的工作区文件)来产出种子。 -3. **`live-interactions`**——一段不含工具调用的已录轮次经覆写 sidecar 承载三个仅回放的场景:sidecar 的内容本身写在 spec 里,每次运行时在 spec 自有的临时目录中生成文件(重试追加所用的派生成功条目经 `deriveReplayScript` 从 fixture 重新派生,绝不复制进已提交的 sidecar)。取消:一个带 `readyFile` 标记的 `{ patches }` `hang`——标记文件的存在证明流在测试点击 Stop 之前已停驻在轮次中途,使流中取消按构造即确定(`turn/end` 原因为 `aborted`,输入框重新启用)。AUTH 错误:一次落在 llm-retry 可重试集合之外的分片前 `throw`(`turn/end` 原因为 `error`,零条 `llm/retry` 事件,输入框恢复可用)。SERVER 重试:第 0 次调用 `throw` + 在第 1 次调用处追加 fixture 自身的成功条目,凭持久的 `llm/retry` 记录在浏览器中端到端证明 llm-retry(`request/header` 仅在变化时记录,因此尝试次数在那里不可见)。每个场景都把各自的终态表面钉为一份预期输出:`cancel.expected.md`(冻结的 `partial`、「已停止」标记)、`error-auth.expected.md`(仅有提示词气泡——web-error-surface 缺口的已提交产物,错误渲染落地时翻转的那份 diff)、`retry.expected.md`(与一次干净完成无从区分——重试在文本记录中刻意不可见)。 -4. **`question-composer`**——已交付组合中常驻的 `ask_user_question` 接管:一段已录轮次在真实的 userInteraction seam 上阻塞于步骤中途,提问输入框(`[data-question-key]`)在浏览器中渲染,测试经它作答(这是驱动步骤对模型内容作出反应的唯一获准之处:没有这个回答,轮次无法完成,record 与 replay 皆然),工具结果携带所选的 label。预期输出:提问输入框稳定的等待态(`ui.expected.md`)与已作答的文本记录(`answered.expected.md`——提问已落定为其工具往返加最终回复,接管消失)。 -5. **`steering`**——在提问输入框阻塞该步骤时做轮次中途 steering(中途引导),此即确定性的轮次中途窗口,不依赖任何时序。输入框在运行期间锁定,因此这一 steer 由页面经客户端所用的同一条同源 `/api` wire POST `session.prompt` `mode:'steer'`(`TODO(web-steer-composer)`:待有输入框手势后改为驱动它);下游的一切都是产品路径——gateway → `Agent.steer` → 步骤边界排空 → 持久的 `steering/message` → SSE → 带徽标的插话气泡。record 模式的 fixture 诚实性:除非真实模型的最终回复遵循了一条只有 steering 消息才携带的指令,否则该次录制被拒绝。预期输出以可视方式钉住这一时序语义:`mid-steer.expected.md` 捕捉「已接受但不可见」的状态(循环仅在步骤边界才排空 steering,因此提问仍在阻塞时不存在插话气泡——若 client 日后提前渲染待处理的 steer,这份预期输出会最先翻转),`settled.expected.md` 则捕捉带徽标的气泡加遵循指令的回复。 -6. **`navigation-panes`**——一份内容丰富的双轮次种子(轮次 1:同一条 assistant 消息内的 bash + 两次并行 read;轮次 2:一段 markdown 密集的回复)经 seeded-history 模式冷渲染(零模型调用),承载四个表面:侧栏搜索(客户端标题过滤;仅在持久的标题随 attach 基线一同到达后才断言,因为冷的 `SessionSummary` 不携带标题,而搜索匹配的是用户所见的 `displayTitle`;反例查询清空整棵树,正例查询收窄,清除后复原)、Trajectory 标签页(轮次分节 + 步骤组的工具构成,外加视图区 aria 预期输出)、Waterfall 标签页(span 统计 + 每个 span 一条泳道;只有 assistant/steering 节点携带轮次编号,因此 P-I 折叠会将一个轮次 0 的序幕 span 计入,按原样钉住)与详情列(bash 工具视图行把点击路由到 openDetails;打开/关闭状态断言在 frame 的 `data-details-collapsed` 属性上,因为关闭把网格列收缩到宽度 0 而不卸载子树)。预期输出:`trajectory.expected.md` 与 `waterfall.expected.md`(各自标签页的视图区),外加 `details-open.expected.md`(打开的面板:工具名标题、Input 参数、Output 结果)。 -7. **`lifecycle-chrome`**——一段极小的已录纯文本轮次驱动三个整页关注点。真实 wire 上的 Workspace 动线:空态 hero 的首次发送物化出真实的 Workspace + Session(jsdom 的 `workspace-flow.snapshot.ts` 套件基于 fixture 客户端钉住这一状态机;本场景则经 HTTP RPC + SSE + gateway 钉住它),其持久证据是会话头部的 cwd 恰为按名创建的目标 `/workspace`,外加 hero 等待态的 aria 预期输出。重新加载恢复:折叠侧栏(持久化于 `dsh.layout.panels`),`page.reload`,整个表面纯凭持久化完整归来——布局保持折叠,选中项恢复(`dsh.sessions.current`),已录轮次从 `session.history` 重新渲染且零模型调用(已耗尽的回放游标使任何离群请求都在 close 时大声失败),且 `reloaded.expected.md` 钉住重建后的会话区——纯凭持久化渲染出同一份安定的文本记录,这本身就是恢复主张。暗色模式:本场景直接驱动 ThemeService 的 DOM 契约 seam(即 `body[data-ds-dark-theme]` 属性),并钉住已交付的级联(alias token 翻转,某个实际绘制的表面重绘,移除该属性则精确还原亮色采样值),且独立于设置表面——该表面的真实用户手势归 `settings-chrome` 管;按范围裁定,主题/布局不设预期输出(aria 感知不到颜色)。 -8. **`settings-chrome`**——设置表面(#644),空白 frame 上零模型调用。模态框外壳:侧栏底部的触发按钮(`aria-haspopup`/`aria-expanded`)打开 `role=dialog` 的「设置」,默认激活「通用设置」,其中既有骨架行,也有具备实际功能的「语言」与「外观」两行(对话框 aria 预期输出);分节切换把 `aria-current` 移到刻意留空的「模型」分节;经 Escape 与头部的「关闭」按钮均可关闭。「外观」行是真正的主题手势(lifecycle 场景的 `TODO(web-theme-gesture)` 就此撤除):点击「深色」跑通整条链路(`aria-pressed`、持久化的 `dsh.theme`、`body[data-ds-dark-theme]`、alias token 翻转)并在重新加载后存续;`system` 双向跟随所模拟的操作系统配色方案(`page.emulateMedia`),该 spec 还会恢复「浅色」默认值以保证 spec 之间互不污染。「语言」行把设置范围内的文案切换为 English(`dsh.locale` 持久化,对话框重新注册为 Settings/General/Appearance),在重新加载后存续,最后恢复为「中文」——目前本地化只覆盖设置命名空间,因此该场景断言的恰是这一表面。有意的重新加载会撕断 SSE 流,因此该 spec 恰好只排空自身重新加载引发的重连警告;任何意外的连接丢失仍会触发绊线失败。 -9. **`workspace-management`**——工作区浏览器操作(#643),零模型调用(workspace.create/rename 是 host 侧 RPC;唯一的会话行来自重新播种 seeded-history 已提交的种子,因此没有录制任何新 fixture)。经区域头部的「+」对话框按名创建两次(`workspace.create` 会 mkdir 并把新项前插到持久注册表——host 侧经 `ctx.workspace.list()` 断言)。端到端的重命名:悬停显露的行操作菜单(按钮在所在行悬停之前是 `display:none`)→ Rename 对话框 → 重名预检在发出任何 wire 调用之前就亮出内联 `role=alert` 并禁用主按钮 → 换一个全新名称则走 `workspace.rename` RPC,更新该行、在 host 上持久化并在重新加载后存续。扁平的「In one list」视图:Group by 菜单把分节标签翻转为 Sessions,去掉分组头(播种的会话成为顶层行),在 `dsh.workspace.view` 中持久化并跨重新加载存续,该 spec 最后恢复分组模式。会话悬停卡片在驻留延时后渲染(纯展示,无 aria role——用文本锚定),指针移开即关闭。刻意不驱动:本次迭代以无行为形态交付的纯视觉菜单行(会话的 Rename/Fork/Delete、工作区的 Delete)与拖拽重排——见「暂缓」。 +该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局持久化、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。 ### CI 立场 @@ -70,7 +62,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **用占位 `DEEPSEEK_API_KEY` + 回放拦截替代禁用适配器行。** 尽管零组合改动且树内有两处先例仍被否决:它用谎言满足 `llm-deepseek` 的快速失败密钥检查,还留下一个挂载却被拦截的死适配器;禁用行(ACP overlay 的同款做法)是诚实的无密钥,并在最早可解析点快速失败。 -**`packages/support/web-snapshot` 包 + `defineWebSnapshotSuite` 工厂。** 已否决:驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100%,且两个场景就上工厂是从单一消费方过度泛化,真正共享的逻辑已从受门禁的包中导出。重启条件:出现第二个 web 形态消费方,或 ≥6 个场景的内联分支被证实各自漂移;届时包边界将画在无浏览器一侧。 +**`packages/support/web-snapshot` 包 + `defineWebSnapshotSuite` 工厂。** 已否决:驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100%,且除受门禁的包已导出的辅助工具与本地 scaffold 外,这些场景专用交互尚未形成稳定的无浏览器契约。出现第二个 web 形态消费方,或被证实重复的生命周期代码确立该契约后,再重新考虑。 **第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。 @@ -80,11 +72,11 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **以真实模型浏览器测试充当无密钥车道。** 已否决:按构造即不确定;被调研的前车之鉴(open-webui)长出无界超时后被删除。带密钥的 W5 冒烟仍是真实模型侧的补充。 -**客户端 `data-dsh-busy` 安定信号。** 暂缓:两个场景下多条件安定轮询已经够用,host 侧 `whenIdle` 屏障承担了重活。重启条件:第一次安定轮询抖动,或某场景需要等待 DOM 不暴露的状态。 +**客户端 `data-dsh-busy` 安定信号。** 暂缓:host 侧 `whenIdle` 屏障配合稳定 DOM 轮询,足以覆盖当前场景。第一次安定轮询抖动,或必要状态在 DOM 中不可观察时,再重新考虑。 ## Testing -车道自身:`pnpm run test:web` 与既有冒烟对一起无密钥运行所有场景;`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型重录某场景的 fixture;`DSH_SNAPSHOT=refresh` 无密钥重写各份 aria 预期输出。`paceMs` 校验、节奏下限、节奏中中止、`assertConsumed` 的两种失败形态,以及 `{ patches }` 的接受/拒绝路径(按索引换入保留邻项、`at == length` 追加、越界/非整数大声失败)钉在 `packages/support/llm-replay/tests/llm-replay.spec.ts`。 +`pnpm run test:web` 无密钥运行该车道。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh` 则无密钥重写 aria 预期输出。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 ## 暂缓 @@ -93,7 +85,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 - **Web 错误表面**:客户端不消费任何 `agent/error` 帧,分片前的失败也没有可冻结的部分输出,因此不可重试的提供方失败不渲染任何错误文案——用户看到的只是发送就此停住。AUTH 场景钉住当前契约(不崩溃、输入框恢复可用、轮次记录为 `error`),`FIXME(web-error-surface)` 标记了待 UI 长出错误渲染后断言可见错误文本的位置。 - **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer;`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 -- **拖拽会话重排**:`workspace.insertSessionBefore`(手动排序,#643)尚无浏览器场景——它需要在同一个工作区里物化两个会话(一份双脚本的已录 fixture)外加合成的 HTML5 拖拽事件;当该表面变更或回归时再补充。无行为的菜单行(会话的 Rename/Fork/Delete、工作区的 Delete)待长出行为后获得各自的场景。 +- **拖拽会话重排**:`workspace.insertSessionBefore` 尚无浏览器场景;它需要在同一个工作区里物化两个会话,并合成 HTML5 拖拽事件。当该表面变更或回归时再补充。无行为的会话 Rename/Fork/Delete 和工作区 Delete 菜单行待获得行为后再补充场景。 ## 后果 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 5b16736771..e52e316862 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -17,7 +17,7 @@ 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, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { saveFailureShot } from './support.ts' @@ -103,8 +103,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () // (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 @@ -155,6 +157,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () }, 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 index 46a03281f9..a74833cef6 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -142,6 +142,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { 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 () => { @@ -167,6 +168,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { 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 () => { @@ -194,6 +196,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { 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 () => { diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 361cd72be6..2c2709a8f0 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -71,8 +71,8 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0) if (MODE !== 'record') { - // Golden of the composer's waiting state (the transcript region golden - // is #612's job; this pins the question surface). + // 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) } @@ -98,6 +98,7 @@ describe('web e2e: resident question composer round trip', () => { 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 () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 98f5124490..1f4dc23f90 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -18,7 +18,7 @@ // (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 { 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' @@ -141,7 +141,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { 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/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 1d3c0d52bb..90f0b3964b 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -12,7 +12,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { join } from 'node:path' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { saveFailureShot } from './support.ts' @@ -36,17 +36,6 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => { await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) - /** - * An INTENTIONAL reload tears the SSE stream mid-flight, so the dying - * page's reconnect note is expected — drain exactly those entries so the - * tripwire still fails the spec on any UNEXPECTED connection loss. - */ - const drainReloadWarnings = (): void => { - const kept = tripwire.warnings.filter(text => !/connection lost/i.test(text)) - tripwire.warnings.length = 0 - tripwire.warnings.push(...kept) - } - afterAll(async () => { await browser?.close() await scaffold?.close() @@ -114,9 +103,10 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => { 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 }) - drainReloadWarnings() + acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.emulateMedia({ colorScheme: 'light' }) const reloaded = await readState() expect(reloaded.attr).toBe(true) @@ -158,9 +148,10 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => { 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 }) - drainReloadWarnings() + acknowledgeReloadConnectionLoss(tripwire, warningStart) const enTrigger = page.getByRole('button', { name: 'Settings' }) await enTrigger.waitFor({ timeout: 10_000 }) await enTrigger.click() diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index e3ed1ddac8..9b023c21d2 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -162,6 +162,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { 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 () => { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 9aa857f731..a19211c6bf 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -12,7 +12,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, + acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { saveFailureShot } from './support.ts' @@ -45,17 +45,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) - /** - * An INTENTIONAL reload tears the SSE stream mid-flight, so the dying - * page's reconnect note is expected — drain exactly those entries so the - * tripwire still fails the spec on any UNEXPECTED connection loss. - */ - const drainReloadWarnings = (): void => { - const kept = tripwire.warnings.filter(text => !/connection lost/i.test(text)) - tripwire.warnings.length = 0 - tripwire.warnings.push(...kept) - } - afterAll(async () => { await browser?.close() await scaffold?.close() @@ -108,9 +97,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover car 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 }) - drainReloadWarnings() + acknowledgeReloadConnectionLoss(tripwire, warningStart) await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) expect(tripwire.pageErrors).toEqual([]) }, 90_000) @@ -129,9 +119,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover car 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 }) - drainReloadWarnings() + 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() diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 90b45ecbca..3ce492e8ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -688,7 +688,7 @@ export interface ReplayModelConfig { } ``` -Source: [`packages/support/llm-replay/src/index.ts:497`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:590`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index 2cf2dbe216..b583dc7125 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -47,13 +47,10 @@ export function markLlmAdapterFailure( const error = value instanceof Error ? value as Error & { code?: string } : new HarnessError(String(value), 'UNKNOWN', { cause: value }) - // The own `failure` data property is the serializable boundary contract: - // validated field-by-field and cross-checked against the error's own code, - // then honored on ANY Error — an instanceof gate here would drop the facts - // exactly when class identity is lost (a second copy of this package in - // the process, e.g. a source-plane test harness over a lib-plane boot). + // Cross-package copies preserve own data but not class identity. Trust the + // carried facts only when both own properties agree after validation. const carried = ownFailureSnapshot(error) - const failure = carried !== undefined && carried.code === foreignErrorCode(error) ? carried : Object.freeze({ + const failure = carried !== undefined && carried.code === ownErrorCode(error) ? carried : Object.freeze({ message: errorMessage(error), code: harnessErrorCode(error), }) @@ -61,13 +58,12 @@ export function markLlmAdapterFailure( return error } -/** Read a foreign error's `code` for the cross-check without letting an SDK accessor replace the primary failure. */ -function foreignErrorCode(error: Error & { code?: string }): unknown { +/** Read a foreign error's own data-backed `code` without invoking accessors. */ +function ownErrorCode(error: Error): unknown { try { - return error.code - } catch (_sdkCodeGetter) { - // An unreadable code cannot confirm the carried facts describe this - // error; the caller falls back to the normalized snapshot. + const descriptor = Object.getOwnPropertyDescriptor(error, 'code') + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined + } catch (_sdkPropertyTrap) { return undefined } } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 7c9f632a20..9d3539494c 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -291,6 +291,34 @@ describe('LlmService', () => { expect(facts).not.toBe(carried) }) + it('keeps validated failure facts across package copies with matching own codes', async () => { + const original = Object.assign(new Error('provider busy'), { + code: 'RATE_LIMIT', + failure: { + message: 'provider busy', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 1_500, + requestId: 'req-cross-copy', + }, + }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ + message: 'provider busy', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 1_500, + requestId: 'req-cross-copy', + }) + }) + it('keeps an unknown SDK Error exact without trusting its private code or accessors', async () => { const original = Object.assign(new Error('socket closed'), { code: 'ECONNRESET' }) Object.defineProperty(original, 'failure', { @@ -324,10 +352,7 @@ describe('LlmService', () => { expect(llmFailureOf(stream, original)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' }) }) - it('keeps an SDK Error exact when a valid failure payload rides a hostile code accessor', async () => { - // The carried-facts cross-check reads error.code; a throwing accessor - // there must fall back to the normalized snapshot instead of replacing - // the original adapter error with the accessor exception. + it('keeps an SDK Error exact without trusting accessor-backed carried facts', async () => { const original = Object.assign(new Error('busy'), { failure: { message: 'busy', code: 'SERVER', status: 503 }, }) @@ -345,6 +370,46 @@ describe('LlmService', () => { expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' }) }) + it('does not trust carried facts matched only by an inherited code', async () => { + class InheritedCodeError extends Error { + get code(): string { return 'SERVER' } + } + const original = Object.assign(new InheritedCodeError('busy'), { + failure: { message: 'busy', code: 'SERVER', status: 503 }, + }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' }) + }) + + it('keeps an SDK Error exact when code descriptor inspection is trapped', async () => { + const target = Object.assign(new Error('busy'), { + code: 'SERVER', + failure: { message: 'busy', code: 'SERVER', status: 503 }, + }) + const original = new Proxy(target, { + getOwnPropertyDescriptor(value, property) { + if (property === 'code') throw new Error('SDK code descriptor trap') + return Reflect.getOwnPropertyDescriptor(value, property) + }, + }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' }) + }) + it('falls back safely when SDK objects trap failure inspection or expose malformed facts', async () => { const propertyTrap = new Proxy(new HarnessError('descriptor trapped', 'SERVER'), { getOwnPropertyDescriptor(target, property) { diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index f75e17d36a..be5b6a02de 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -71,12 +71,13 @@ export interface Scenario { recorded: boolean /** * Whether replay is driven by a hand-written `replay.override.json` sidecar - * (a `ReplayEntry[]` that REPLACES the script derived from `session.jsonl`) - * — the throw/hang cases chunks cannot express. The fixture guard requires - * the sidecar exactly when this is set: the harness forwards the file purely - * on existence, so an unregistered stray sidecar would silently replace the - * derived script — the guard fails loud on either mismatch. Defaults to - * false (replay derives from the fixture's `assistant/chunk` events). + * (a `ReplayOverrideDoc` that replaces or patches the script derived from + * `session.jsonl`) — the throw/hang cases chunks cannot express. The fixture + * guard requires the sidecar exactly when this is set: the harness forwards + * the file purely on existence, so an unregistered stray sidecar would + * silently alter the derived script. The guard fails loud on either + * mismatch. Defaults to false (replay derives from the fixture's + * `assistant/chunk` events). */ overridden?: boolean /** diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 9039715a71..7ce4a5ee56 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/README.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 -README.md: f184e271ff9e68760db43cfe79d4f39be81ef00f -README.zh.md: a47bc81ab747fcdc130d535e116979e45304b319 +README.md: ce0758641f3d49a54b29415ed449e43043840f9a +README.zh.md: 47a2b9aa211b44c4e476a1adf5a9a72d927cd0ed diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index f184e271ff..ce0758641f 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -10,7 +10,7 @@ Its consumers are the ACP, headless `stream-json`, and TUI snapshot suites plus The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` events and the line-0 session header. -Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either REPLACES the derived script (a bare `ReplayEntry[]`) or AUGMENTS it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call, swap only the named 0-based call indexes; `at` equal to the derived length appends — the slot for the retry attempt that follows an injected transient throw). A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. +Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. ## Nested agents: per-session keying @@ -23,7 +23,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s | Key | Type | Default | Notes | |---|---|---|---| | `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). | -| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. | +| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session: a bare `ReplayEntry[]` replaces its derived script, while `{ patches }` augments it by call index. | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | | `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. | | `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. | @@ -48,9 +48,9 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s - `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars. - `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. -- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the PRIMARY session only (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing). +- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing). - `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. -- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. +- Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. ## Plugin export shape @@ -67,4 +67,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently (or a compaction summarize call landing mid-run) would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`). -- **Only chunk-producing calls are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar; the override replaces the PRIMARY session's script only. +- **Only chunk-producing calls are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs. diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index a47bc81ab7..47a2b9aa21 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -10,7 +10,7 @@ Fixture 就是持久化会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件携带每个 `StreamChunk`,因此按 `(turn, step)` 对其分组可重建每次 `stream()` 调用的分片序列(每个 loop 步骤一次模型调用)。因此,录制操作是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件不执行录制。Fixture 的 `request/header` 内容可能被 token 化为 `{{system}}`/`{{tools}}`(harness 在一个场景中固定该内容,并擦除其余场景);回放对此并不关心,因为派生只读取 `assistant/chunk` 事件和第 0 行会话 header。 -有两种失败 mode 无法仅从 `assistant/chunk` 重建:在任何分片前纯抛出(例如 HTTP 401,日志只包含 `turn/end {error}` 而没有分片),以及 cancel/hang(是时序,而非分片内容)。需要这些的场景提供可选 sidecar(`/replay.override.json`),它要么替换派生脚本(裸 `ReplayEntry[]`),要么增补派生脚本(`{ patches: [{ at, entry }] }`:保留全部由 JSONL 派生的调用,仅在点名的调用索引处换入,索引从 0 计;`at` 等于派生长度时为追加,正是注入的瞬态抛出之后那次重试尝试所占的槽位)。`hang` 条目可以指定 `readyFile`;在其前缀分片到达 loop 后、等待取消前,回放会写入该空标记,使外部驱动器可以在不观察展示更新的情况下确定性取消。 +有两种失败 mode 无法仅从 `assistant/chunk` 重建:在任何分片前纯抛出(例如 HTTP 401,日志只包含 `turn/end {error}` 而没有分片),以及 cancel/hang(是时序,而非分片内容)。需要这些的场景提供可选 sidecar(`/replay.override.json`),它要么替换派生脚本(裸 `ReplayEntry[]`),要么增补派生脚本(`{ patches: [{ at, entry }] }`:保留全部由 JSONL 派生的调用,仅在点名的调用索引处换入,索引从 0 计;`at` 等于派生长度时为追加,正是注入的瞬态抛出之后那次重试尝试所占的槽位)。Patch 索引必须互不重复。覆写文档、每个 patch 与每个条目,以及每个分片的判别字段都会在文件加载时接受校验。`hang` 条目可以指定 `readyFile`;在其前缀分片到达 loop 后、等待取消前,回放会写入该空标记,使外部驱动器可以在不观察展示更新的情况下确定性取消。 ## 嵌套 agent:每会话键控 @@ -23,7 +23,7 @@ Fixture 就是持久化会话日志(`/session.jsonl`)。其 `assis | 键 | 类型 | 默认值 | 说明 | |---|---|---|---| | `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env)。 | -| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 替换主会话派生脚本的 `ReplayEntry[]` sidecar 可选路径。 | +| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` sidecar:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 | | `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个模型可以发布 `contextWindow`;已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | | `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察的 web SSE mux)看到真正的增量传递。它只是仿真开关,测试不得依赖它保证正确性。值必须是非负整数;pace 等待期间中止会迅速取消流。 | @@ -48,9 +48,9 @@ Fixture 就是持久化会话日志(`/session.jsonl`)。其 `assis - `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于 HMR 安全的 `dispose()`,以及 `assertConsumed()` 拆卸检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 - `loadSessionScripts(config)`:解析场景的有序 `SessionScript[]` (主级 + 子级),准备按首次调用顺序绑定到实时会话。 -- `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]` (如果存在则使用 sidecar override,否则从 JSONL 派生;fixture 缺失时快速失败)。 +- `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]` (如果存在则使用经校验的 sidecar 替换或 patch,否则从 JSONL 派生;fixture 缺失时快速失败)。 - `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)`:将已记录会话日志转换为脚本并读取其 header `id`/`createdAt` 的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是已抛出 `stream()` 的指纹,必须改用 override sidecar 表达。 -- 类型 `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 +- 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 ## 插件导出形态 @@ -67,4 +67,4 @@ Fixture 就是持久化会话日志(`/session.jsonl`)。其 `assis ## 已知限制与待完成工作 - **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut(或运行中落地的压缩摘要调用)会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。 -- **只有生产分片的调用可派生**:纯分片前抛出或 cancel/hang 场景需要 `replay.override.json` sidecar;override 只替换主会话的脚本。 +- **只有生产分片的调用可派生**:纯分片前抛出或 cancel/hang 场景需要 `replay.override.json` sidecar。替换和 patch 两种形式都只影响主会话;子会话脚本仍从各自日志派生。 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 4eb042d4f5..193079ed81 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -59,7 +59,7 @@ export interface ReplayConfig { */ file: string /** - * Optional sidecar for the PRIMARY session: a bare `ReplayEntry[]` REPLACES + * Optional sidecar for the PRIMARY session: a bare `ReplayEntry[]` replaces * the derived script; `{ patches }` keeps it and swaps the named call * indexes ({@link ReplayOverrideDoc}). Used by single-session scenarios not * expressible as `assistant/chunk` (throw-before-chunk, cancel/hang, @@ -214,13 +214,105 @@ export interface ReplayOverridePatch { } /** - * Override sidecar document: either the legacy whole-script replacement (a + * Override sidecar document: either a whole-script replacement (a * bare `ReplayEntry[]`) or the augmentation form `{ patches }`, which keeps * the JSONL-derived script and swaps only the named call indexes — the shape * for "turn N errors, everything else replays as recorded". */ export type ReplayOverrideDoc = ReplayEntry[] | { patches: ReplayOverridePatch[] } +const REPLAY_CHUNK_TYPES = new Set([ + 'block-start', + 'text-delta', + 'reasoning-delta', + 'tool-call-delta', + 'block-end', + 'usage', + 'finish', +]) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)) +} + +function invalidOverride(file: string, location: string, detail: string): never { + throw new Error(`llm-replay: invalid override ${file}: ${location} ${detail}`) +} + +function readChunks(value: unknown, file: string, location: string): StreamChunk[] { + if (!Array.isArray(value)) invalidOverride(file, location, 'chunks must be an array') + for (const [index, chunk] of value.entries()) { + if (!isRecord(chunk) + || typeof chunk['type'] !== 'string' + || !REPLAY_CHUNK_TYPES.has(chunk['type'] as StreamChunk['type'])) { + invalidOverride(file, `${location}.chunks[${index}]`, 'must have a known StreamChunk type') + } + } + return value as StreamChunk[] +} + +function readReplayEntry(value: unknown, file: string, location: string): ReplayEntry { + if (!isRecord(value)) invalidOverride(file, location, 'must be an object') + switch (value['kind']) { + case 'chunks': { + if (!hasExactKeys(value, ['kind', 'chunks'])) invalidOverride(file, location, 'has invalid chunks-entry fields') + return { kind: 'chunks', chunks: readChunks(value['chunks'], file, location) } + } + case 'throw': { + if (!hasExactKeys(value, ['kind', 'chunks', 'message', 'code'])) { + invalidOverride(file, location, 'has invalid throw-entry fields') + } + if (typeof value['message'] !== 'string' || value['message'].length === 0) { + invalidOverride(file, location, 'message must be a non-empty string') + } + if (typeof value['code'] !== 'string' || value['code'].length === 0) { + invalidOverride(file, location, 'code must be a non-empty string') + } + return { + kind: 'throw', + chunks: readChunks(value['chunks'], file, location), + message: value['message'], + code: value['code'], + } + } + case 'hang': { + const readyFile = value['readyFile'] + const keys = readyFile === undefined ? ['kind'] : ['kind', 'readyFile'] + if (!hasExactKeys(value, keys)) invalidOverride(file, location, 'has invalid hang-entry fields') + if (readyFile !== undefined && (typeof readyFile !== 'string' || readyFile.length === 0)) { + invalidOverride(file, location, 'readyFile must be a non-empty string') + } + return { kind: 'hang', ...(readyFile === undefined ? {} : { readyFile }) } + } + default: + return invalidOverride(file, location, `has unknown kind ${JSON.stringify(value['kind'])}`) + } +} + +function readOverrideDoc(value: unknown, file: string): ReplayOverrideDoc { + if (Array.isArray(value)) return value.map((entry, index) => readReplayEntry(entry, file, `entry ${index}`)) + if (!isRecord(value) || !hasExactKeys(value, ['patches']) || !Array.isArray(value['patches'])) { + return invalidOverride(file, 'document', 'must be a ReplayEntry[] or { patches: [...] }') + } + return { + patches: value['patches'].map((value, index): ReplayOverridePatch => { + const location = `patch ${index}` + if (!isRecord(value) || !hasExactKeys(value, ['at', 'entry'])) { + return invalidOverride(file, location, 'must contain exactly at and entry') + } + const at = value['at'] + if (typeof at !== 'number' || !Number.isSafeInteger(at) || at < 0) { + return invalidOverride(file, location, 'at must be a non-negative safe integer') + } + return { at, entry: readReplayEntry(value['entry'], file, `${location}.entry`) } + }), + } +} + /** * Load the PRIMARY session's replay script: the sidecar override when present * (whole-script replacement or `{ patches }` augmentation over the derived @@ -231,20 +323,22 @@ export type ReplayOverrideDoc = ReplayEntry[] | { patches: ReplayOverridePatch[] */ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { if (config.overrideFile !== undefined && existsSync(config.overrideFile)) { - const parsed: unknown = JSON.parse(readFileSync(config.overrideFile, 'utf8')) - if (Array.isArray(parsed)) return parsed as ReplayEntry[] - const doc = parsed as { patches?: unknown } - if (typeof parsed !== 'object' || parsed === null || !Array.isArray(doc.patches)) { - throw new Error(`llm-replay: override must be a ReplayEntry[] or { patches: [...] }: ${config.overrideFile}`) - } + const doc = readOverrideDoc(JSON.parse(readFileSync(config.overrideFile, 'utf8')) as unknown, config.overrideFile) + if (Array.isArray(doc)) return doc const script = deriveScriptFromFile(config.file) - for (const patch of doc.patches as ReplayOverridePatch[]) { - if (!Number.isInteger(patch.at) || patch.at < 0 || patch.at > script.length) { + const derivedLength = script.length + const seenIndexes = new Set() + for (const patch of doc.patches) { + if (patch.at > derivedLength) { throw new Error( `llm-replay: override patch index ${String(patch.at)} out of range ` - + `(derived script has ${script.length} call(s); == length appends): ${config.overrideFile}`, + + `(derived script has ${derivedLength} call(s); == length appends): ${config.overrideFile}`, ) } + if (seenIndexes.has(patch.at)) { + throw new Error(`llm-replay: duplicate override patch index ${patch.at}: ${config.overrideFile}`) + } + seenIndexes.add(patch.at) script[patch.at] = patch.entry } return script @@ -397,9 +491,8 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined, }) /* v8 ignore next -- unreachable: the hang promise only ever rejects (on abort), never resolves; control never reaches here */ return + /* v8 ignore next -- sidecar entries are validated before they reach the closed local union. */ default: - // Closed local union: an unknown kind means malformed (hand-edited or - // drifted) sidecar data — fail loud with a runtime diagnostic. return assertNever(entry, 'llm-replay replay entry') } } diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 1bd8d47405..09ae63dc91 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -203,11 +203,11 @@ describe('loadReplayScript', () => { expect(() => loadReplayScript({ file: join(dir, 'absent.jsonl') })).toThrow(/fixture not found/) }) - it('throws when the override is not a JSON array', () => { + it('rejects an override document that is neither supported form', () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') writeFileSync(overrideFile, '{"not":"array"}', 'utf8') - expect(() => loadReplayScript({ file, overrideFile })).toThrow(/ReplayEntry\[\] or \{ patches/) + expect(() => loadReplayScript({ file, overrideFile })).toThrow(/document must be a ReplayEntry\[\] or \{ patches/) }) it('patches form: swaps the named call index and keeps derived siblings', () => { @@ -249,11 +249,46 @@ describe('loadReplayScript', () => { it('patches form: an out-of-range index fails loud with the derived length', () => { writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') const overrideFile = join(dir, 'replay.override.json') - for (const at of [2, -1, 1.5]) { - writeFileSync(overrideFile, JSON.stringify({ patches: [{ at, entry: { kind: 'hang' } }] }), 'utf8') - expect(() => loadReplayScript({ file, overrideFile })).toThrow(/patch index .* out of range/) + writeFileSync(overrideFile, JSON.stringify({ patches: [{ at: 2, entry: { kind: 'hang' } }] }), 'utf8') + expect(() => loadReplayScript({ file, overrideFile })).toThrow(/patch index 2 out of range.*1 call/s) + }) + + it('validates patch and entry shapes at the file boundary', () => { + writeFileSync(file, sessionJsonl([]), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + const invalid: Array<{ doc: unknown; message: RegExp }> = [ + { doc: null, message: /document must be/ }, + { doc: { patches: [null] }, message: /patch 0 must contain exactly at and entry/ }, + { doc: { patches: [{ at: -1, entry: { kind: 'hang' } }] }, message: /at must be a non-negative safe integer/ }, + { doc: { patches: [{ at: 1.5, entry: { kind: 'hang' } }] }, message: /at must be a non-negative safe integer/ }, + { doc: [42], message: /entry 0 must be an object/ }, + { doc: [{ kind: 'chunks', chunks: 'nope' }], message: /chunks must be an array/ }, + { doc: [{ kind: 'chunks', chunks: [], extra: true }], message: /invalid chunks-entry fields/ }, + { doc: [{ kind: 'chunks', chunks: [{ type: 'bogus' }] }], message: /known StreamChunk type/ }, + { doc: [{ kind: 'throw', chunks: [], message: 'nope', code: 'AUTH', extra: true }], message: /invalid throw-entry fields/ }, + { doc: [{ kind: 'throw', chunks: [], message: '', code: 'AUTH' }], message: /message must be a non-empty string/ }, + { doc: [{ kind: 'throw', chunks: [], message: 'nope', code: '' }], message: /code must be a non-empty string/ }, + { doc: [{ kind: 'hang', extra: true }], message: /invalid hang-entry fields/ }, + { doc: [{ kind: 'hang', readyFile: 1 }], message: /readyFile must be a non-empty string/ }, + { doc: [{ kind: 'bogus' }], message: /unknown kind/ }, + ] + for (const { doc, message } of invalid) { + writeFileSync(overrideFile, JSON.stringify(doc), 'utf8') + expect(() => loadReplayScript({ file, overrideFile })).toThrow(message) } }) + + it('rejects duplicate patch indexes instead of silently taking the last one', () => { + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify({ + patches: [ + { at: 0, entry: { kind: 'hang' } }, + { at: 0, entry: { kind: 'throw', chunks: [], message: 'busy', code: 'SERVER' } }, + ], + }), 'utf8') + expect(() => loadReplayScript({ file, overrideFile })).toThrow(/duplicate override patch index 0/) + }) }) describe('installLlmReplay (through the real LlmService)', () => { @@ -409,16 +444,14 @@ describe('installLlmReplay (through the real LlmService)', () => { .toEqual([{ type: 'finish', reason: { kind: 'stop' } }]) }) - it('throws on a malformed sidecar entry kind (the assertNever guard)', async () => { + it('rejects a malformed sidecar entry kind before installing replay', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') // A kind the union does not know — hand-edited/drifted sidecar data. writeFileSync(overrideFile, JSON.stringify([{ kind: 'bogus' }]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) - installLlmReplay(ctx, { file, overrideFile }) - await expect(drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))) - .rejects.toThrow(/llm-replay replay entry/) + expect(() => installLlmReplay(ctx, { file, overrideFile })).toThrow(/unknown kind/) }) it('rejects a hang entry when the signal fires DURING the wait (abort listener path)', async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 7d27be62ff..c7af95061a 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -998,8 +998,9 @@ describe('resume command and /resume', () => { await tick(); await tick() result.terminal.send('Fallback target') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:') + }) expect(result.terminal.output).toContain('dsh --resume fallback-session') expect(result.terminal.stopped).toBe(0) await dispose(result) @@ -1019,8 +1020,9 @@ describe('resume command and /resume', () => { await tick(); await tick() result.terminal.send('No fallback target') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Session is resumable, but this host cannot hand it off in place') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Session is resumable, but this host cannot hand it off in place') + }) await dispose(result) })