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 37782efde2..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: a0e912f83e3d2fb219a174731e7545604ebed305 -2026-07-24-web-gui-browser-e2e-lane.zh.md: aa1014de1868be1b999a701fb97f55613a50047b +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 a0e912f83e..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 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 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,18 +32,17 @@ 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. +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 `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 with no run-local `rpcId`). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](../../archived/testing/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. +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 @@ -63,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. @@ -73,17 +72,20 @@ 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 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`. +`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 - **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins the web composition's 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. +- **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 aa1014de18..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`)。 +`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,18 +32,17 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 预期输出 -每场景一份提交的预期输出:会话区规范化 `ariaSnapshot()`(`ui.expected.md`)——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 重新生成 `ui.expected.md`。两个场景的 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点,不含当次运行的 `rpcId`)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](../../archived/testing/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` 工具读取播种的工作区文件)来产出种子。 +该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局持久化、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。 ### CI 立场 @@ -63,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 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。 @@ -73,17 +72,20 @@ 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` 的两种失败形态钉在 `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 校验、按索引替换与唯一的追加位置。 ## 暂缓 - **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 web 组合的提示词/工具 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)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 +- **拖拽会话重排**:`workspace.insertSessionBefore` 尚无浏览器场景;它需要在同一个工作区里物化两个会话,并合成 HTML5 拖拽事件。当该表面变更或回归时再补充。无行为的会话 Rename/Fork/Delete 和工作区 Delete 菜单行待获得行为后再补充场景。 ## 后果 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index d6b432d4c6..6771616378 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -79,6 +79,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 937db88382..d60f30dede 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -45,6 +45,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/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts new file mode 100644 index 0000000000..e52e316862 --- /dev/null +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -0,0 +1,163 @@ +// Web e2e scenarios: lifecycle & chrome — the workspace-aware first-send +// flow over the real wire, reload recovery, and the dark-mode token cascade. +// One tiny recorded turn (text-only) drives the whole spec: the empty-state +// hero materializes a real Workspace + Session on first send (the jsdom +// workspace-flow suite pins the object-layer state machine over the fixture +// client; THIS spec pins the same flow through HTTP RPC + SSE + the host +// gateway), reload replays everything from the log (zero further model +// calls), and the theme scenario proves the shipped dark palette actually +// cascades: attribute -> alias token flip -> painted surface change. Per the +// lane's scope ruling there is no theme/layout golden (aria is color-blind); +// the hero's waiting state gets the one golden here. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') +// Post-reload golden: the same settled conversation rebuilt purely from +// persistence + history — byte-equal rendering is exactly the recovery claim. +const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') +const MODE = webSnapshotMode() + +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' + +describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('sends the first prompt from the empty-state hero (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + // The blank frame renders the hero, not the resident composer: the + // headline plus the guidance placeholder are the empty state's anchors. + await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + if (MODE !== 'record') { + // Golden of the hero's stable waiting state (captured before any send; + // the conversation-region goldens belong to the other scenarios). + const snapshot = await captureStableAria(page, '[class*="frame"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE) + } + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 200_000) + + it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize')) + // Browser: the sidebar tree now carries the auto-created workspace group + // with its one session, and the opened session is the selected row. + await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // Host: the session's durable header cwd is the workspace flow's + // create-by-name target (/workspace, the composer's + // default draft name) — the proof the send went through workspace + // materialization rather than a bare default-cwd session. + const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd) + expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')]) + const turnEnds = sessionEvents.filter(e => e.type === 'turn/end') + expect(turnEnds).toHaveLength(1) + expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') + }, 60_000) + + it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload')) + // Fold a layout preference into the same reload: collapse the sidebar + // (persisted under dsh.layout.panels) before reloading. + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + // Layout persisted: the sidebar comes back collapsed. + await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1) + // Selection persisted (dsh.sessions.current) and history replayed: the + // recorded turn re-renders from session.history with zero model calls — + // the replay cursor was fully consumed before the reload, so any stray + // request would fail the scenario loudly at close(). + await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // Expand back and confirm the tree still lists the materialized session. + await page.getByRole('button', { name: 'Open sidebar' }).click() + await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1) + // Golden of the recovered conversation region: rebuilt from the log, it + // must render the same settled transcript the live turn produced. + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark')) + // This scenario pins the ThemeService's DOM contract seam directly (the + // body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL + // user gesture above it (Settings -> Appearance cubes) is owned by + // settings-chrome.e2e.ts. Driving the attribute here keeps the cascade + // pinned independently of the settings surface's own lifecycle. + const sample = async (): Promise<{ token: string; sidebarBg: string; bodyBg: string }> => + await page.evaluate(() => { + const sidebar = document.querySelector('[class*="sidebar"], [class*="rail"]') ?? document.body + return { + token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(), + sidebarBg: getComputedStyle(sidebar).backgroundColor, + bodyBg: getComputedStyle(document.body).backgroundColor, + } + }) + const light = await sample() + await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) + const dark = await sample() + // The alias token itself must flip — the cascade's root fact. + expect(dark.token).not.toBe(light.token) + // And a real painted surface must consume it (not just variables in a + // void): at least one of the sampled backgrounds repaints. + expect(dark.sidebarBg !== light.sidebarBg || dark.bodyBg !== light.bodyBg).toBe(true) + // Removing the attribute restores the light values exactly (the palettes + // live in one stylesheet; activation is attribute-only by design). + await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) + const restored = await sample() + expect(restored).toEqual(light) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md']) + }) +}) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts new file mode 100644 index 0000000000..a74833cef6 --- /dev/null +++ b/apps/web/tests/live-interactions.e2e.ts @@ -0,0 +1,207 @@ +// Web e2e scenarios: live-turn interactions — cancellation, error surfacing, +// and transient-retry recovery, all through the real composition and wire. +// The model seam is dsh-llm-replay with override sidecars: `hang` (+ a +// readyFile marker) makes mid-stream cancel deterministic by construction, +// `throw` entries express provider failures by stable code, and `{ patches }` +// augmentation injects a transient throw before the recorded success so +// llm-retry's recovery is proven end-to-end in the browser. Sidecar CONTENT +// is authored here (single-sourced against the fixture via deriveReplayScript +// — no committed copy of recorded chunks); the file is a per-run artifact in +// the temp workspace. One recorded base fixture serves all three scenarios. +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +// One golden per interactive end-state: what the user is left looking at +// after cancel, after a non-retryable failure (pins the FIXME(web-error-surface) +// gap as a reviewable artifact: NO error copy in the tree), and after retry +// recovery — three genuinely different terminal surfaces of one fixture. +const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md') +const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md') +const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md') +const MODE = webSnapshotMode() + +// The recorded base: one text-only turn whose derived script the sidecars +// patch. Kept deliberately tool-free so the derived script is exactly one +// model call. +const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.' + +/** turn/end reasons observed, in order. */ +function turnEndReasons(events: SessionEvent[]): string[] { + return events + .filter(e => e.type === 'turn/end') + .map(e => (e as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind) +} + +describe('web e2e: live-turn interactions (cancel / error / retry)', () => { + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page + let tripwire: ReturnType + let sessionEvents: SessionEvent[] + let sidecarDir: string | undefined + + afterEach(async () => { + // scaffold.close() failures MUST fail the scenario: assertConsumed() is + // the fixture-drift tripwire and cleanup problems are real defects. Run + // every teardown step regardless, then rethrow what failed. + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + browser = undefined + const closing = scaffold + scaffold = undefined + await closing?.close().catch((error: unknown) => failures.push(error)) + if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + sidecarDir = undefined + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'live-interactions teardown failed') + }) + + /** Boot scaffold + page with an optional override doc materialized per run. */ + async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise { + sessionEvents = [] + let overridePath: string | undefined + if (buildOverride !== undefined) { + // The sidecar CONTENT is authored in this spec; the file is a per-run + // artifact minted in a spec-owned temp dir. It must exist BEFORE the + // scaffold boots — installLlmReplay resolves the script at install. + sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-')) + overridePath = join(sidecarDir, 'replay.override.json') + await writeFile(overridePath, JSON.stringify(buildOverride(sidecarDir))) + } + scaffold = await launchWebScaffold({ + replayFixture: FIXTURE, + ...(overridePath === undefined ? {} : { replayOverride: overridePath }), + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + } + + /** + * 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) + // Golden of the aborted end-state: the prompt bubble plus the frozen + // partial ('partial' is the hang entry's replayed prefix) and no more. + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 120_000) + + it.skipIf(MODE === 'record')('surfaces a non-retryable AUTH failure without retrying', async () => { + await launch(() => ({ + patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }], + })) + onTestFailed(() => saveFailureShot(page, 'web-e2e-error-auth')) + const { settled } = await sendPrompt() + await settled + expect(turnEndReasons(sessionEvents).at(-1)).toBe('error') + // AUTH is outside llm-retry's retryable set: no retry record. + expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0) + // Product gap found by this lane, pinned as-is: the client consumes no + // agent/error frames and a pre-chunk failure freezes no partial, so THIS + // failure renders no error copy anywhere — the user sees the send simply + // stop. FIXME(web-error-surface): assert visible error text here once the + // web UI grows an error rendering; until then the pinned contract is + // "no crash, composer recovers, turn logged as error". + await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + expect(await page.locator('[data-streaming="true"]').count()).toBe(0) + // Golden of the same gap: the prompt bubble alone, no error copy in the + // tree — the diff that changes when web-error-surface lands. + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(ERROR_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 120_000) + + it.skipIf(MODE === 'record')('recovers a transient SERVER failure through llm-retry and completes', async () => { + const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8'))) + expect(derived).toHaveLength(1) + await launch(() => ({ + patches: [ + { at: 0, entry: { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' } }, + // Append the fixture's own success as the retry attempt — single- + // sourced from the recording, never copied into a committed sidecar. + { at: 1, entry: derived[0]! }, + ], + })) + onTestFailed(() => saveFailureShot(page, 'web-e2e-retry')) + // llm-retry backs off ~500ms before the second attempt. + const { settled } = await sendPrompt(60_000) + await settled + expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed') + // The durable retry record proves the second attempt (request/header logs + // only on change, so attempt count is invisible there). + expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1) + await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0) + // Golden of the recovered end-state: indistinguishable from a clean + // completion — retries are deliberately invisible in the transcript. + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 120_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md', + ]) + }) +}) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts new file mode 100644 index 0000000000..bbae7363df --- /dev/null +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -0,0 +1,190 @@ +// Web e2e scenarios: navigation & panes — the view tabs (Trajectory / +// Waterfall), the details column, and sidebar search, all over ONE rich +// two-turn seeded fixture rendered purely from the log (the seeded-history +// pattern: zero model calls in replay, so every surface here is the client +// fold + host history RPC, not replay binding). The seed is recorded live +// under the standard discipline: turn 1 produces a bash call plus two +// parallel reads in one assistant message (tool-call density for the +// trajectory/waterfall lanes and a details-capable bash row), turn 2 a +// markdown-rich reply (a second turn so the waterfall has two lanes). +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url)) +const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') +const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') +const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md') +const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'navigation-panes-web-e2e' + +// Turn 1 leads with a distinctive word: the session-title fallback takes the +// first words of the first message, so the sidebar-search scenario has a +// known-matching query ('navscenario') without depending on a live title call. +const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.' +const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.' + +describe('web e2e: navigation & panes over a rich seeded session', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // The workspace-aware flow runs sessions in /workspace; + // the read targets must live in that session cwd (pre-creation is safe: + // create-by-name adopts an existing directory). + const sessionCwd = join(scaffold.workspaceCwd, 'workspace') + await mkdir(sessionCwd, { recursive: true }) + await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n') + await writeFile(join(sessionCwd, 'nav-b.md'), '# beta nav\n') + if (MODE !== 'record') { + const raw = await readFile(SEED, 'utf8') + expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts') + .toEqual([PROMPT_TURN1, PROMPT_TURN2]) + await seedSession(scaffold, raw, SEED_ID) + } + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record')) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + let sessionId: Awaited> | undefined + for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) { + const settled = scaffold.whenTurnSettled() + // Turn 2 types into the same composer once turn 1 unlocks it. + await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true) + await input.fill(prompt) + await input.press('Enter') + sessionId = await settled + } + await recordFixture(scaffold, sessionId!, SEED) + // Fixture honesty: the recording must carry the shape the replay + // scenarios assert on — three calls in turn 1 and two closed turns. + const recorded = parseSessionLog(await readFile(SEED, 'utf8')) + expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2) + const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call') + expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read']) + }, 400_000) + + it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open')) + // Expand the collapsed group row, then open the revealed session row. + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1) + }, 90_000) + + it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) + // Runs after the session is open: a cold summary carries no title (the + // sidebar shows the cwd basename), and the durable title lands with the + // attach subscription's baseline — which is itself worth pinning: search + // matches the title the user sees, not a hidden cold field. + const search = page.getByPlaceholder('Search name, keywords', { exact: false }) + await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // Negative: a garbage query empties the tree (group rows hide too). + await search.fill('zzzqx-no-such-session') + await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0) + // Positive: a title word narrows to the matched session + its group, + // force-expanded by search mode (case-insensitive client-side filter). + await search.fill('navscenario') + await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + // Clear restores the unfiltered tree. + await page.getByRole('button', { name: 'Clear search' }).click() + await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') + await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) + }, 60_000) + + it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) + await page.getByRole('tab', { name: 'Trajectory' }).click() + // Two sticky turn sections; turn 1's step group summarizes its tool mix + // (bash + the two parallel reads collapse to 'bash read×2'). + await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1) + const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + }, 60_000) + + it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall')) + await page.getByRole('tab', { name: 'Waterfall' }).click() + // The stats header rides the waterfall body. The span fold counts THREE + // spans for this two-turn log: only assistant/steering nodes carry a turn + // number, so the first user message lands in a turn-0 prologue span (a + // P-I placeholder shape — pinned as-is; real spans are deferred to + // P-III per the view's deviation ledger). Calls: bash + two reads. + await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1) + // One lane per span, tagged by turn number, prologue included. + for (const tag of ['turn 0', 'turn 1', 'turn 2']) { + await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1) + } + const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE) + }, 60_000) + + it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) + await page.getByRole('tab', { name: 'Chat' }).click() + // The bash toolview row routes its click to openDetails (read rows are + // expand-in-place instead — the seeded-history scenario owns that fold). + const bashRow = page.locator('[data-sample="bash-global"]').first() + await bashRow.waitFor({ timeout: 15_000 }) + // Open/closed is the frame's collapsed attribute: the column collapses to + // width 0 but its subtree deliberately never unmounts (hidden, not + // absent), so element presence/visibility cannot express the state. + const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() + expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() + await bashRow.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull() + // The open panel shows the selected call's name, arguments, and durable + // result (NAVIGATION_OK appears in the chat row too, hence >= 2 total). + await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + // Golden of the open panel: tool name header, Input args, Output result. + const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE) + await page.getByRole('button', { name: '关闭详情' }).click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull() + }, 60_000) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md', + ]) + }) +}) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts new file mode 100644 index 0000000000..2c2709a8f0 --- /dev/null +++ b/apps/web/tests/question-composer.e2e.ts @@ -0,0 +1,107 @@ +// 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') +// 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.' + +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') { + // 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) + } + + await composer.getByRole('radio', { name: 'Blue' }).click() + // Submit: Enter on the focused option (the composer's documented submit). + await composer.getByRole('radio', { name: 'Blue' }).press('Enter') + + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + return + } + // World state: the tool result carries the chosen answer, and DONE lands. + const results = sessionEvents.filter(e => e.type === 'tool/result') + expect(JSON.stringify(results.at(-1))).toContain('Blue') + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // Composer gone; regular input restored. + expect(await page.locator('[data-question-key]').count()).toBe(0) + await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + // Golden of the answered transcript: the ask_user_question round trip + // rendered as history (question tool row + DONE), composer takeover gone. + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 200_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e4a7450288..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' @@ -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 /** @@ -135,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 new file mode 100644 index 0000000000..90f0b3964b --- /dev/null +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -0,0 +1,170 @@ +// Web e2e scenarios: the settings surface — the modal shell (trigger, nav, +// section switching, both close paths), the Appearance preference row (the +// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme +// -> theme/change -> ui-layout's presenter -> body attribute -> alias token) +// and the Language row (settings-scoped localization + persisted dsh.locale). +// Zero model calls: everything is pure client + persistence state on a blank +// frame, so there is no fixture and a stray stream would fail loud on the +// open llm seam. +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { join } from 'node:path' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url)) +const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') +const MODE = webSnapshotMode() + +describe('web e2e: settings modal, appearance gesture, language switch', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('opens the settings dialog, switches sections, and closes by every path', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell')) + const trigger = page.getByRole('button', { name: '设置', exact: true }) + expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog') + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + await trigger.click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.waitFor({ timeout: 10_000 }) + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + // General is the active section by default; its skeleton rows plus the + // functional Language and Appearance rows render. + expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') + await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) + await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1) + // Golden of the freshly opened dialog (default zh, General active). + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) + // Section switch: aria-current moves; Models is deliberately empty. + await dialog.getByRole('button', { name: '模型' }).click() + await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') + expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull() + // Close path 1: Escape. + await page.keyboard.press('Escape') + await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + // Close path 2: the header close button (focus lands there on open). + await trigger.click() + await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click() + await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('flips the theme through the Appearance cubes and persists across reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) + const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> => + await page.evaluate(() => ({ + attr: document.body.hasAttribute('data-ds-dark-theme'), + token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(), + stored: localStorage.getItem('dsh.theme'), + })) + // Pin the OS scheme to light so the default `system` preference resolves + // light and the dark flip below is unambiguously the gesture's doing. + await page.emulateMedia({ colorScheme: 'light' }) + const light = await readState() + expect(light.attr).toBe(false) + + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.waitFor({ timeout: 10_000 }) + const darkCube = dialog.getByRole('button', { name: '深色' }) + expect(await darkCube.getAttribute('aria-pressed')).toBe('false') + await darkCube.click() + // The full cascade: pressed state, persisted preference, body attribute, + // alias token flip — all from one real user gesture. + await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') + const dark = await readState() + expect(dark.attr).toBe(true) + expect(dark.stored).toBe('dark') + expect(dark.token).not.toBe(light.token) + await page.keyboard.press('Escape') + + // Reload: the preference survives boot (restore + presenter initial apply). + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.emulateMedia({ colorScheme: 'light' }) + const reloaded = await readState() + expect(reloaded.attr).toBe(true) + expect(reloaded.stored).toBe('dark') + + // `system` follows the emulated OS scheme (dark stays dark, light clears). + await page.getByRole('button', { name: '设置', exact: true }).click() + const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' }) + await systemCube.click() + await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') + await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + await page.emulateMedia({ colorScheme: 'dark' }) + await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) + // Restore for the specs that follow: light preference beats the emulated + // dark OS scheme, leaving the shared page in the light default. + await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click() + await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it('switches the settings surface language and persists dsh.locale', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language')) + await page.getByRole('button', { name: '设置', exact: true }).click() + const zhDialog = page.getByRole('dialog', { name: '设置' }) + await zhDialog.waitFor({ timeout: 10_000 }) + // The Language selector pill shows the active locale's own name. + const selector = zhDialog.getByRole('button', { name: '中文' }) + expect(await selector.getAttribute('aria-haspopup')).toBe('menu') + await selector.click() + await page.getByRole('menuitem', { name: 'English' }).click() + // The settings-owned copy re-registers localized: dialog title, nav, + // Appearance labels. (Only the settings namespaces are localized today — + // the rest of the app's copy is intentionally out of this row's scope.) + const enDialog = page.getByRole('dialog', { name: 'Settings' }) + await enDialog.waitFor({ timeout: 10_000 }) + expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') + await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) + expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en') + // Reload keeps English; then restore zh so shared page state (and the + // other specs' 设置-anchored selectors + goldens) see the default again. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + const enTrigger = page.getByRole('button', { name: 'Settings' }) + await enTrigger.waitFor({ timeout: 10_000 }) + await enTrigger.click() + await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click() + await page.getByRole('menuitem', { name: '中文' }).click() + await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 }) + expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh') + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md new file mode 100644 index 0000000000..407e1c7c5a --- /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 "设置": + - img + - text: 设置 +- text: Let's start building +- button "Choose workspace": + - img + - text: workspace + - img +- textbox "Describe what you want to build" +- button "Add attachment": + - img +- combobox "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/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/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/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/live-interactions/session.jsonl b/apps/web/tests/snapshots/live-interactions/session.jsonl new file mode 100644 index 0000000000..69f99d1277 --- /dev/null +++ b/apps/web/tests/snapshots/live-interactions/session.jsonl @@ -0,0 +1,93 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784998084441,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1784998084454,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784998084457,"data":{"title":"Reply with a one-sentence description","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784998084519,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784998084520,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784998084900,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1784998084900,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1784998085053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1784998085056,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":9,"time":1784998085056,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":10,"time":1784998085085,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":11,"time":1784998085086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1784998085086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":13,"time":1784998085086,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-s"}}} +{"type":"assistant/chunk","seq":14,"time":1784998085114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"entence"}}} +{"type":"assistant/chunk","seq":15,"time":1784998085114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} +{"type":"assistant/chunk","seq":16,"time":1784998085115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":17,"time":1784998085115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" event"}}} +{"type":"assistant/chunk","seq":18,"time":1784998085115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sourcing"}}} +{"type":"assistant/chunk","seq":19,"time":1784998085115,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1784998085143,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" This"}}} +{"type":"assistant/chunk","seq":21,"time":1784998085143,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":22,"time":1784998085143,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":23,"time":1784998085143,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" straightforward"}}} +{"type":"assistant/chunk","seq":24,"time":1784998085172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" knowledge"}}} +{"type":"assistant/chunk","seq":25,"time":1784998085173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":26,"time":1784998085173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":27,"time":1784998085202,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" doesn"}}} +{"type":"assistant/chunk","seq":28,"time":1784998085202,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":29,"time":1784998085202,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" require"}}} +{"type":"assistant/chunk","seq":30,"time":1784998085231,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":31,"time":1784998085231,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" skill"}}} +{"type":"assistant/chunk","seq":32,"time":1784998085231,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" loading"}}} +{"type":"assistant/chunk","seq":33,"time":1784998085267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} +{"type":"assistant/chunk","seq":34,"time":1784998085267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":35,"time":1784998085288,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} +{"type":"assistant/chunk","seq":36,"time":1784998085317,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":37,"time":1784998085318,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":38,"time":1784998085318,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"Event"}}} +{"type":"assistant/chunk","seq":39,"time":1784998085318,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" sourcing"}}} +{"type":"assistant/chunk","seq":40,"time":1784998085346,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":41,"time":1784998085346,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" a"}}} +{"type":"assistant/chunk","seq":42,"time":1784998085346,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" pattern"}}} +{"type":"assistant/chunk","seq":43,"time":1784998085375,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" where"}}} +{"type":"assistant/chunk","seq":44,"time":1784998085376,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" all"}}} +{"type":"assistant/chunk","seq":45,"time":1784998085404,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" changes"}}} +{"type":"assistant/chunk","seq":46,"time":1784998085433,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" to"}}} +{"type":"assistant/chunk","seq":47,"time":1784998085434,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" an"}}} +{"type":"assistant/chunk","seq":48,"time":1784998085434,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" application"}}} +{"type":"assistant/chunk","seq":49,"time":1784998085434,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"'s"}}} +{"type":"assistant/chunk","seq":50,"time":1784998085467,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" state"}}} +{"type":"assistant/chunk","seq":51,"time":1784998085467,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" are"}}} +{"type":"assistant/chunk","seq":52,"time":1784998085467,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" stored"}}} +{"type":"assistant/chunk","seq":53,"time":1784998085495,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" as"}}} +{"type":"assistant/chunk","seq":54,"time":1784998085495,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" an"}}} +{"type":"assistant/chunk","seq":55,"time":1784998085520,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" immutable"}}} +{"type":"assistant/chunk","seq":56,"time":1784998085520,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":57,"time":1784998085521,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" append"}}} +{"type":"assistant/chunk","seq":58,"time":1784998085550,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"-only"}}} +{"type":"assistant/chunk","seq":59,"time":1784998085551,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" sequence"}}} +{"type":"assistant/chunk","seq":60,"time":1784998085551,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" of"}}} +{"type":"assistant/chunk","seq":61,"time":1784998085551,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" events"}}} +{"type":"assistant/chunk","seq":62,"time":1784998085551,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":63,"time":1784998085551,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" rather"}}} +{"type":"assistant/chunk","seq":64,"time":1784998085579,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" than"}}} +{"type":"assistant/chunk","seq":65,"time":1784998085579,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" pers"}}} +{"type":"assistant/chunk","seq":66,"time":1784998085609,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"isting"}}} +{"type":"assistant/chunk","seq":67,"time":1784998085609,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" only"}}} +{"type":"assistant/chunk","seq":68,"time":1784998085609,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":69,"time":1784998085609,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" current"}}} +{"type":"assistant/chunk","seq":70,"time":1784998085638,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" state"}}} +{"type":"assistant/chunk","seq":71,"time":1784998085639,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":72,"time":1784998085666,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" enabling"}}} +{"type":"assistant/chunk","seq":73,"time":1784998085666,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" full"}}} +{"type":"assistant/chunk","seq":74,"time":1784998085695,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" audit"}}} +{"type":"assistant/chunk","seq":75,"time":1784998085696,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ability"}}} +{"type":"assistant/chunk","seq":76,"time":1784998085726,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":77,"time":1784998085726,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" temporal"}}} +{"type":"assistant/chunk","seq":78,"time":1784998085754,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" queries"}}} +{"type":"assistant/chunk","seq":79,"time":1784998085754,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":80,"time":1784998085754,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" and"}}} +{"type":"assistant/chunk","seq":81,"time":1784998085754,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" event"}}} +{"type":"assistant/chunk","seq":82,"time":1784998085782,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"-driven"}}} +{"type":"assistant/chunk","seq":83,"time":1784998085782,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" architectures"}}} +{"type":"assistant/chunk","seq":84,"time":1784998085813,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":85,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."}}}} +{"type":"assistant/chunk","seq":86,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}}}} +{"type":"assistant/chunk","seq":87,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":88,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} +{"type":"step/end","seq":90,"time":1784998085820,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":91,"time":1784998085821,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md new file mode 100644 index 0000000000..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/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/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/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl new file mode 100644 index 0000000000..81ef3a5f6c --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -0,0 +1,147 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785001700711,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1785001700724,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1785001700725,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785001700727,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785001700783,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785001700784,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785001701372,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1785001701373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785001701490,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785001701513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785001701513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785001701513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785001701513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":12,"time":1785001701514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1785001701540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} +{"type":"assistant/chunk","seq":14,"time":1785001701541,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_user"}}} +{"type":"assistant/chunk","seq":15,"time":1785001701541,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":16,"time":1785001701541,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"question"}}} +{"type":"assistant/chunk","seq":17,"time":1785001701541,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":18,"time":1785001701541,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":19,"time":1785001701566,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} +{"type":"assistant/chunk","seq":20,"time":1785001701566,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":21,"time":1785001701566,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":22,"time":1785001701566,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":23,"time":1785001701593,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":24,"time":1785001701594,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" id"}}} +{"type":"assistant/chunk","seq":25,"time":1785001701618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":26,"time":1785001701619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"color"}}} +{"type":"assistant/chunk","seq":27,"time":1785001701619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":28,"time":1785001701619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":29,"time":1785001701619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":30,"time":1785001701646,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Which"}}} +{"type":"assistant/chunk","seq":31,"time":1785001701646,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" color"}}} +{"type":"assistant/chunk","seq":32,"time":1785001701646,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":33,"time":1785001701646,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" you"}}} +{"type":"assistant/chunk","seq":34,"time":1785001701646,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prefer"}}} +{"type":"assistant/chunk","seq":35,"time":1785001701647,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"?\","}}} +{"type":"assistant/chunk","seq":36,"time":1785001701681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" header"}}} +{"type":"assistant/chunk","seq":37,"time":1785001701681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":38,"time":1785001701681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Pick"}}} +{"type":"assistant/chunk","seq":39,"time":1785001701681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":40,"time":1785001701681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":41,"time":1785001701682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":42,"time":1785001701699,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" options"}}} +{"type":"assistant/chunk","seq":43,"time":1785001701699,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" labeled"}}} +{"type":"assistant/chunk","seq":44,"time":1785001701699,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":45,"time":1785001701699,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Blue"}}} +{"type":"assistant/chunk","seq":46,"time":1785001701700,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1785001701700,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":48,"time":1785001701727,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":49,"time":1785001701728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Green"}}} +{"type":"assistant/chunk","seq":50,"time":1785001701728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":51,"time":1785001701728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":52,"time":1785001701756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":53,"time":1785001701756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":54,"time":1785001701756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":55,"time":1785001701778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":56,"time":1785001701778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":57,"time":1785001701858,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":58,"time":1785001701858,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":59,"time":1785001701885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":60,"time":1785001701886,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1785001701886,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"questions"}}} +{"type":"assistant/chunk","seq":62,"time":1785001701886,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":63,"time":1785001701886,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":64,"time":1785001701910,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"["}}} +{"type":"assistant/chunk","seq":65,"time":1785001701911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":66,"time":1785001701911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"id"}}} +{"type":"assistant/chunk","seq":67,"time":1785001701911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":68,"time":1785001701939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":69,"time":1785001701939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"color"}}} +{"type":"assistant/chunk","seq":70,"time":1785001701939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":71,"time":1785001701939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":72,"time":1785001701939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"question"}}} +{"type":"assistant/chunk","seq":73,"time":1785001701940,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":74,"time":1785001701964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":75,"time":1785001701964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"Which"}}} +{"type":"assistant/chunk","seq":76,"time":1785001701964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" color"}}} +{"type":"assistant/chunk","seq":77,"time":1785001701965,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" do"}}} +{"type":"assistant/chunk","seq":78,"time":1785001701965,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" you"}}} +{"type":"assistant/chunk","seq":79,"time":1785001701965,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" prefer"}}} +{"type":"assistant/chunk","seq":80,"time":1785001701991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"?\","}}} +{"type":"assistant/chunk","seq":81,"time":1785001701991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":82,"time":1785001701991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"header"}}} +{"type":"assistant/chunk","seq":83,"time":1785001701991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":84,"time":1785001701991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":85,"time":1785001701991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"Pick"}}} +{"type":"assistant/chunk","seq":86,"time":1785001702017,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" one"}}} +{"type":"assistant/chunk","seq":87,"time":1785001702017,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":88,"time":1785001702018,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":89,"time":1785001702018,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"options"}}} +{"type":"assistant/chunk","seq":90,"time":1785001702018,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":91,"time":1785001702018,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" ["}}} +{"type":"assistant/chunk","seq":92,"time":1785001702043,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":93,"time":1785001702043,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"label"}}} +{"type":"assistant/chunk","seq":94,"time":1785001702043,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":95,"time":1785001702043,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":96,"time":1785001702043,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"Blue"}}} +{"type":"assistant/chunk","seq":97,"time":1785001702046,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\"},"}}} +{"type":"assistant/chunk","seq":98,"time":1785001702069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" {\""}}} +{"type":"assistant/chunk","seq":99,"time":1785001702070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"label"}}} +{"type":"assistant/chunk","seq":100,"time":1785001702070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":101,"time":1785001702070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":102,"time":1785001702070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"Green"}}} +{"type":"assistant/chunk","seq":103,"time":1785001702070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1785001702096,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"}]"}}} +{"type":"assistant/chunk","seq":105,"time":1785001702097,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"}]"}}} +{"type":"assistant/chunk","seq":106,"time":1785001702123,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":107,"time":1785001702154,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask 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."}}}} +{"type":"assistant/chunk","seq":108,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}}}} +{"type":"assistant/chunk","seq":109,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}}}} +{"type":"assistant/chunk","seq":110,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":111,"time":1785001702159,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"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."},{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":112,"time":1785001702160,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}} +{"type":"tool/result","seq":113,"time":1785001702566,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"} +{"type":"step/end","seq":114,"time":1785001702568,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":115,"time":1785001702569,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":116,"time":1785001702948,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":117,"time":1785001702949,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":118,"time":1785001703033,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":119,"time":1785001703059,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answered"}}} +{"type":"assistant/chunk","seq":120,"time":1785001703060,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":121,"time":1785001703060,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Blue"}}} +{"type":"assistant/chunk","seq":122,"time":1785001703060,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":123,"time":1785001703060,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":124,"time":1785001703060,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":125,"time":1785001703089,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":126,"time":1785001703089,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":127,"time":1785001703089,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":128,"time":1785001703111,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":129,"time":1785001703111,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":130,"time":1785001703112,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":131,"time":1785001703112,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":132,"time":1785001703112,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":133,"time":1785001703112,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":134,"time":1785001703139,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":135,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":136,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":137,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":138,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":139,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."}}}} +{"type":"assistant/chunk","seq":140,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":141,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":142,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":143,"time":1785001703141,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142],"surfaceOp":"append"} +{"type":"step/end","seq":144,"time":1785001703142,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":145,"time":1785001703142,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md new file mode 100644 index 0000000000..3368b373a0 --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -0,0 +1,23 @@ +- region "Which color do you prefer?": + - text: Pick one + - heading "Which color do you prefer?" [level=2] + - text: 1 / 1 + - button "上一题" [disabled]: + - img + - button "下一题" [disabled]: + - img + - button "放弃整组问题": + - img + - radiogroup: + - radio "Blue": + - text: 1 Blue + - img + - radio "Green": + - text: 2 Green + - img + - button "其他,请填写自定义答案": + - img + - text: 其他,请填写自定义答案 + - status + - button "跳过本题" + - button "提交" [disabled] diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md new file mode 100644 index 0000000000..75959994f1 --- /dev/null +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -0,0 +1,30 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "关闭": + - img + - text: 关闭 + - text: 权限 选择默认权限模式 + - button "Read only" [disabled]: + - text: Read only + - img + - text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言 + - button "中文": + - text: 中文 + - img + - text: 外观 + - button "浅色": + - img + - text: 浅色 + - button "深色": + - img + - text: 深色 + - button "跟随系统" [pressed]: + - img + - text: 跟随系统 diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md new file mode 100644 index 0000000000..a26bbb7bd8 --- /dev/null +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -0,0 +1,39 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - text: · 1 turns + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - tab "Waterfall" +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. +- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": + - img + - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. +- button +- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)" +- button "▸ 问题内容" +- text: 请在原客户端处理(web 端作答后续里程碑提供) cache hit 98% · 7,946 tokens · 1 turns · 1 steps +- region "Ready to continue?": + - text: Checkpoint + - heading "Ready to continue?" [level=2] + - text: 1 / 1 + - button "上一题" [disabled]: + - img + - button "下一题" [disabled]: + - img + - button "放弃整组问题": + - img + - radiogroup: + - radio "Yes": + - text: 1 Yes + - img + - radio "No": + - text: 2 No + - img + - button "其他,请填写自定义答案": + - img + - text: 其他,请填写自定义答案 + - status + - button "跳过本题" + - button "提交" [disabled] diff --git a/apps/web/tests/snapshots/steering/session.jsonl b/apps/web/tests/snapshots/steering/session.jsonl new file mode 100644 index 0000000000..5d8b4506f6 --- /dev/null +++ b/apps/web/tests/snapshots/steering/session.jsonl @@ -0,0 +1,144 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785004180013,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1785004180030,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1785004180030,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785004180033,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785004180105,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785004180106,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785004180696,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1785004180697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785004180785,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785004180814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785004180815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785004180815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785004180815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":12,"time":1785004180815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1785004180843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} +{"type":"assistant/chunk","seq":14,"time":1785004180843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_user"}}} +{"type":"assistant/chunk","seq":15,"time":1785004180843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":16,"time":1785004180844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"question"}}} +{"type":"assistant/chunk","seq":17,"time":1785004180844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":18,"time":1785004180844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":19,"time":1785004180874,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} +{"type":"assistant/chunk","seq":20,"time":1785004180875,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" them"}}} +{"type":"assistant/chunk","seq":21,"time":1785004180902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":22,"time":1785004180902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":23,"time":1785004180902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":24,"time":1785004180902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":25,"time":1785004180902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":26,"time":1785004180930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" given"}}} +{"type":"assistant/chunk","seq":27,"time":1785004180931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameters"}}} +{"type":"assistant/chunk","seq":28,"time":1785004180931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1785004180961,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":30,"time":1785004180961,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":31,"time":1785004180961,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":32,"time":1785004180961,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":33,"time":1785004180989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":34,"time":1785004180990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":35,"time":1785004181077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785004181078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":37,"time":1785004181105,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":38,"time":1785004181106,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1785004181106,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"questions"}}} +{"type":"assistant/chunk","seq":40,"time":1785004181106,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1785004181106,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":42,"time":1785004181134,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"["}}} +{"type":"assistant/chunk","seq":43,"time":1785004181135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":44,"time":1785004181135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"id"}}} +{"type":"assistant/chunk","seq":45,"time":1785004181135,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":46,"time":1785004181164,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":47,"time":1785004181165,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"check"}}} +{"type":"assistant/chunk","seq":48,"time":1785004181165,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"point"}}} +{"type":"assistant/chunk","seq":49,"time":1785004181165,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":50,"time":1785004181165,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":51,"time":1785004181165,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"question"}}} +{"type":"assistant/chunk","seq":52,"time":1785004181193,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":53,"time":1785004181194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":54,"time":1785004181194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"Ready"}}} +{"type":"assistant/chunk","seq":55,"time":1785004181194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":56,"time":1785004181194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" continue"}}} +{"type":"assistant/chunk","seq":57,"time":1785004181194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"?\","}}} +{"type":"assistant/chunk","seq":58,"time":1785004181223,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":59,"time":1785004181223,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"header"}}} +{"type":"assistant/chunk","seq":60,"time":1785004181223,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":61,"time":1785004181223,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":62,"time":1785004181223,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"Check"}}} +{"type":"assistant/chunk","seq":63,"time":1785004181224,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"point"}}} +{"type":"assistant/chunk","seq":64,"time":1785004181252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":65,"time":1785004181252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":66,"time":1785004181252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"options"}}} +{"type":"assistant/chunk","seq":67,"time":1785004181252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":68,"time":1785004181252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" ["}}} +{"type":"assistant/chunk","seq":69,"time":1785004181253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":70,"time":1785004181281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"label"}}} +{"type":"assistant/chunk","seq":71,"time":1785004181281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":72,"time":1785004181281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":73,"time":1785004181281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"Yes"}}} +{"type":"assistant/chunk","seq":74,"time":1785004181281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\"},"}}} +{"type":"assistant/chunk","seq":75,"time":1785004181281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" {\""}}} +{"type":"assistant/chunk","seq":76,"time":1785004181309,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"label"}}} +{"type":"assistant/chunk","seq":77,"time":1785004181309,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":78,"time":1785004181310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":79,"time":1785004181310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"No"}}} +{"type":"assistant/chunk","seq":80,"time":1785004181310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":81,"time":1785004181310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"}]"}}} +{"type":"assistant/chunk","seq":82,"time":1785004181338,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"}]"}}} +{"type":"assistant/chunk","seq":83,"time":1785004181368,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":84,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."}}}} +{"type":"assistant/chunk","seq":85,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}}} +{"type":"assistant/chunk","seq":86,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":87,"time":1785004181402,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} +{"type":"tool/call","seq":89,"time":1785004181407,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}} +{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} +{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"step/end","seq":92,"time":1785004181870,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":93,"time":1785004181870,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":94,"time":1785004182322,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":95,"time":1785004182323,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":96,"time":1785004182452,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":97,"time":1785004182480,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" selected"}}} +{"type":"assistant/chunk","seq":98,"time":1785004182481,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":99,"time":1785004182481,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Yes"}}} +{"type":"assistant/chunk","seq":100,"time":1785004182481,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":101,"time":1785004182509,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":102,"time":1785004182510,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":103,"time":1785004182539,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":104,"time":1785004182539,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":105,"time":1785004182539,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" include"}}} +{"type":"assistant/chunk","seq":106,"time":1785004182569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":107,"time":1785004182570,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":108,"time":1785004182597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":109,"time":1785004182597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":110,"time":1785004182597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AN"}}} +{"type":"assistant/chunk","seq":111,"time":1785004182597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ANA"}}} +{"type":"assistant/chunk","seq":112,"time":1785004182626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":113,"time":1785004182627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":114,"time":1785004182627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" my"}}} +{"type":"assistant/chunk","seq":115,"time":1785004182627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}} +{"type":"assistant/chunk","seq":116,"time":1785004182627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":117,"time":1785004182627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":118,"time":1785004182655,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":119,"time":1785004182656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":120,"time":1785004182656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" acknowledge"}}} +{"type":"assistant/chunk","seq":121,"time":1785004182685,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" their"}}} +{"type":"assistant/chunk","seq":122,"time":1785004182749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":123,"time":1785004182749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":124,"time":1785004182750,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":125,"time":1785004182750,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Great"}}} +{"type":"assistant/chunk","seq":126,"time":1785004182774,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":127,"time":1785004182774,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" let"}}} +{"type":"assistant/chunk","seq":128,"time":1785004182802,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"'s"}}} +{"type":"assistant/chunk","seq":129,"time":1785004182804,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" move"}}} +{"type":"assistant/chunk","seq":130,"time":1785004182804,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" forward"}}} +{"type":"assistant/chunk","seq":131,"time":1785004182831,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":132,"time":1785004182862,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" B"}}} +{"type":"assistant/chunk","seq":133,"time":1785004182863,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"AN"}}} +{"type":"assistant/chunk","seq":134,"time":1785004182863,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ANA"}}} +{"type":"assistant/chunk","seq":135,"time":1785004182863,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"!"}}} +{"type":"assistant/chunk","seq":136,"time":1785004182892,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."}}}} +{"type":"assistant/chunk","seq":137,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Great, let's move forward. BANANA!"}}}} +{"type":"assistant/chunk","seq":138,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":139,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":140,"time":1785004182894,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."},{"type":"text","text":"Great, let's move forward. BANANA!"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139],"surfaceOp":"append"} +{"type":"step/end","seq":141,"time":1785004182895,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":142,"time":1785004182895,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md new file mode 100644 index 0000000000..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/snapshots/workspace-management/.gitkeep b/apps/web/tests/snapshots/workspace-management/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts new file mode 100644 index 0000000000..9b023c21d2 --- /dev/null +++ b/apps/web/tests/steering.e2e.ts @@ -0,0 +1,171 @@ +// Web e2e scenario: mid-turn steering, end to end. The composer locks while a +// turn runs, so the product UI has no steering gesture yet — the steer is +// POSTed from the page itself over the same same-origin /api transport the +// client uses (TODO(web-steer-composer): drive this through a composer +// gesture once one exists). Everything downstream is product: the gateway +// routes mode:'steer' to Agent.steer, the loop drains it at the step +// boundary into a durable steering/message event, the SSE mux pushes it, and +// the transcript renders the badged interjection bubble. The question +// composer supplies the deterministic mid-turn window: while ask_user_question +// blocks, the turn is provably running, so record and replay perform the +// identical steer-then-answer sequence with zero timing dependence — and the +// recorded final reply proves the steer reached the MODEL (it obeys an +// instruction that only the steering message carries). +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +// Two goldens for the two distinct states this interaction produces: the +// mid-turn moment (steer ACCEPTED but deliberately invisible — the loop +// drains steering at the step boundary, so no interjection bubble exists +// while the question still blocks the step) and the settled transcript +// (badged bubble in place, final reply obeying it). The pair pins the +// timing semantics visually: if the client ever starts rendering pending +// steers eagerly, the mid-steer golden flips first. +const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() + +const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.' +const STEER = 'Interjection: include the word BANANA in your final reply.' + +/** Concatenated assistant text deltas — the model-visible reply body. */ +function assistantText(events: SessionEvent[]): string { + return events + .filter(e => e.type === 'assistant/chunk') + .map((e) => { + const chunk = (e as SessionEvent & { data: { chunk: { type: string; text?: string } } }).data.chunk + return chunk.type === 'text-delta' ? chunk.text ?? '' : '' + }) + .join('') +} + +describe('web e2e: mid-turn steering lands durably and visibly', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let liveSessionId: string | undefined + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }) + scaffold.ctx.on('session/event', (session, event) => { + liveSessionId ??= session.id + sessionEvents.push(event) + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('steers during the blocked step; the interjection is logged, rendered, and obeyed', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-steering')) + if (MODE !== 'record') { + // The steer must NOT be a user/message — it lands as steering/message. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000) + await input.fill(PROMPT) + await input.press('Enter') + + // The blocked composer is the mid-turn barrier: its presence proves the + // ask_user_question step is executing, i.e. the turn is running NOW. + const composer = page.locator('[data-question-key]') + await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) + + // Steer through the real wire from the page (same envelope + endpoint the + // web client's session.prompt uses). accepted:true is the transport proof. + expect(liveSessionId).toBeDefined() + const reply = await page.evaluate(async ({ sessionId, text }) => { + const response = await fetch('/api/session.prompt', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: crypto.randomUUID(), + method: 'session.prompt', + payload: { sessionId, mode: 'steer', content: [{ type: 'text', text }] }, + }), + }) + return await response.json() as { result?: { ok?: boolean } } + }, { sessionId: liveSessionId!, text: STEER }) + expect(reply.result?.ok).toBe(true) + + if (MODE !== 'record') { + // Mid-turn golden: the ACCEPTED steer is durable in the inbox but the + // loop drains steering only at the step boundary, so no steering/message + // exists yet and no interjection bubble renders — the composer still + // blocks, alone. The DOM is stable here (no further SSE frames can + // arrive until the question is answered), making this state capturable. + expect(await page.getByText('插话').count()).toBe(0) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE) + } + + // Answer the composer; the tool result closes the step, the loop drains + // the steer as steering/message, and the steered continuation runs the + // final model call. + await composer.getByRole('radio', { name: 'Yes' }).click() + await composer.getByRole('radio', { name: 'Yes' }).press('Enter') + await settled + + if (MODE === 'record') { + const sessionId = await settled + await recordFixture(scaffold, sessionId, FIXTURE) + // Fixture honesty: a recording where the live model ignored the steer + // would replay as a vacuous scenario — reject it and re-record instead. + const recorded = parseSessionLog(await readFile(FIXTURE, 'utf8')) + expect(recorded.filter(e => e.type === 'steering/message')).toHaveLength(1) + expect(assistantText(recorded)).toContain('BANANA') + return + } + + // Durable: exactly one steering/message, inside turn 1, carrying the text. + const steerEvents = sessionEvents.filter(e => e.type === 'steering/message') + expect(steerEvents).toHaveLength(1) + expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1) + expect(JSON.stringify(steerEvents[0])).toContain('BANANA') + const turnEnds = sessionEvents.filter(e => e.type === 'turn/end') + expect(turnEnds).toHaveLength(1) + expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') + + // Visible: the badged interjection bubble plus the reply that obeys it + // (steer text + final reply each contain the marker word). + await expect.poll(() => page.getByText('插话').count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + expect(await page.locator('[data-question-key]').count()).toBe(0) + // Settled golden: badge + interjection between the question round trip + // and the obeying reply, composer takeover gone. + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 200_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md']) + }) +}) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts new file mode 100644 index 0000000000..a19211c6bf --- /dev/null +++ b/apps/web/tests/workspace-management.e2e.ts @@ -0,0 +1,160 @@ +// 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 { + acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', import.meta.url)) +// The seed is another scenario's committed fixture, reused read-only: this +// spec needs any one cold session row, not new recorded content. +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'workspace-management-web-e2e' + +describe('web e2e: workspace management (create / rename / flat view / hover card)', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + 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) + + 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') + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it('switches to the flat "In one list" view and persists the preference', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat')) + // Grouped default: workspace group rows render (the seeded session sits + // under Ungrouped; the created workspaces are empty groups). + await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await page.getByRole('button', { name: 'Group by' }).click() + await page.getByRole('menuitem', { name: 'In one list' }).click() + // Flat mode: the section label flips and the seeded session is a + // top-level row with no group headers above it. + await expect.poll(() => page.getByText('Sessions', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 5_000 }).toBe(0) + await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) + expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view'))).toContain('flat') + // Persisted across reload; then restore grouped for inter-spec hygiene. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0) + await page.getByRole('button', { name: 'Group by' }).click() + await page.getByRole('menuitem', { name: 'WorkSpace' }).click() + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it('shows the session hover card after a dwell on the row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) + // Expand Ungrouped to reveal the seeded session row, then dwell on it + // (the card opens after a 500ms hover delay, portaled to body). + 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 4ada8c7815..8bed531598 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,13 @@ // cannot see both sides of the cordis Context merges). "exclude": [ "tests/scaffold.ts", + "tests/live-interactions.e2e.ts", + "tests/question-composer.e2e.ts", + "tests/steering.e2e.ts", + "tests/navigation-panes.e2e.ts", + "tests/lifecycle-chrome.e2e.ts", + "tests/settings-chrome.e2e.ts", + "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", "tests/seeded-history.e2e.ts", "tests/code-mode-round.e2e.ts" diff --git a/docs/config-catalog.md b/docs/config-catalog.md index defe417f45..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:459`](../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 390282327d..b583dc7125 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -47,8 +47,10 @@ 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 - const failure = carried !== undefined && carried.code === error.code ? carried : Object.freeze({ + // 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 === ownErrorCode(error) ? carried : Object.freeze({ message: errorMessage(error), code: harnessErrorCode(error), }) @@ -56,6 +58,16 @@ export function markLlmAdapterFailure( return error } +/** Read a foreign error's own data-backed `code` without invoking accessors. */ +function ownErrorCode(error: Error): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(error, 'code') + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined + } catch (_sdkPropertyTrap) { + 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..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,6 +352,64 @@ describe('LlmService', () => { expect(llmFailureOf(stream, original)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' }) }) + 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 }, + }) + 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('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 63b9979098..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: 901a3b7b4312fffd93e6d375c378e39064318260 -README.zh.md: b9a8068d329e28933c934e7ad352ac65641f3d23 +README.md: ce0758641f3d49a54b29415ed449e43043840f9a +README.zh.md: 47a2b9aa211b44c4e476a1adf5a9a72d927cd0ed diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 901a3b7b43..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`: 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 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 b9a8068d32..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[]`),以替换派生脚本。`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 f9637536f8..193079ed81 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 /** @@ -200,26 +201,157 @@ 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 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 + * 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}`) + const doc = readOverrideDoc(JSON.parse(readFileSync(config.overrideFile, 'utf8')) as unknown, config.overrideFile) + if (Array.isArray(doc)) return doc + const script = deriveScriptFromFile(config.file) + 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 ${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 parsed as ReplayEntry[] + 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'))) } /** @@ -359,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 584a87abf6..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,91 @@ 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(/not a JSON array/) + expect(() => loadReplayScript({ file, overrideFile })).toThrow(/document must be a 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') + 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/) }) }) @@ -364,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) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76f8f81321..f3f0f0027d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,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 diff --git a/tsconfig.host.json b/tsconfig.host.json index e2765294e2..85f595e01b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -10,6 +10,13 @@ "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/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/web/tests/code-mode-round.e2e.ts",