diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index b289d47edd..d8dae837fe 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-shared-persistence-write-coordinator.md: ea9c4fb74f7c1bd68fb62efedd3e1657da96ea65 -2026-06-18-shared-persistence-write-coordinator.zh.md: 3b4dd7b762c2f39a908eabe23e5d734981b5767b +2026-06-18-shared-persistence-write-coordinator.md: 4632351a6f39c44c9ba8af58d508d4665b9e9279 +2026-06-18-shared-persistence-write-coordinator.zh.md: 40a7144038ac0db4ca6cac651c0a3cef5de4afa9 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index ea9c4fb74f..4632351a6f 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -23,7 +23,7 @@ The coordinator retires a session from `session/disposed`: it waits for the cont Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage: - `name` — backend label for the dispose-failure `AggregateError`. -- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. +- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. - `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). - `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). - `list()` — list all stored metadata. diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 3b4dd7b762..40a7144038 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -23,7 +23,7 @@ Status: implemented 五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 -- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有 cwd bucket;SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 +- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 - `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 - `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 28de5eb97c..932318da4f 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -42,7 +42,7 @@ The agent loop keeps `RequestError` as that exact error object and passes `LlmFa Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them. -The initial shared transient-code set is intentionally small: the adapters' existing `RATE_LIMIT` and `SERVER` mappings plus explicit `TIMEOUT` and `TRANSPORT` codes for the two missing remote-failure families. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum. +The shared transient-code set is intentionally small: adapter mappings for `RATE_LIMIT` and `SERVER`, explicit `TIMEOUT` and `TRANSPORT` codes for remote failures, and `EMPTY_RESPONSE` for a completed provider response with no content blocks. Both adapters classify the last case as an error finish; see [empty model responses are retryable](../bug-fix/2026-07-24-empty-model-response-is-retryable.md). Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum. ### Put retry policy on the existing failed-step seam @@ -62,7 +62,7 @@ interface Config { } ``` -The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. +The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above (`RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, and `EMPTY_RESPONSE`). The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. @@ -124,7 +124,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus durable discarded-attempt markers in append-only ACP and stdio streams. Keyless snapshots cover scheduling, cancellation, success, and exhaustion. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md index 60c3b9b627..23e5f630d3 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md @@ -12,9 +12,9 @@ Windows has atomic namespace operations, but Node does not expose a POSIX-equiva The JSONL backend forks inside `materialize()` before any namespace mutation. Shared code computes the session directory, final log path, and encoded header plus initial event batch; POSIX and Windows then run separate publication protocols. -POSIX keeps the existing protocol: create the root and cwd bucket with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the bucket directory, then remove the redundant temp hard link. +POSIX keeps the existing protocol: create the root, project directory, and session directory with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the session directory, then remove the redundant temp hard link. -Windows creates missing directories through a durable staging publish: create a random sibling directory, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules. +Windows creates missing directories through a durable staging publish: create a random sibling directory under the constant `.dsh-mkdir-` prefix, independent of the target basename, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules. ## Alternatives considered @@ -28,6 +28,6 @@ Windows creates missing directories through a durable staging publish: create a The backend keeps one external contract across platforms: first append either publishes a complete log at the final name or fails without overwriting an existing log. The platform split is an implementation detail; `SessionPersistence` APIs and the logical JSONL record format do not change. The later [Zstandard encoding decision](2026-07-19-zstandard-jsonl-session-logs.md) applies before either platform publishes the opaque bytes. -Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, temp logs are fsync'd before publication, and the resulting log loads normally. +Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, maximum-length target components remain materializable, temp logs are fsync'd before publication, and the resulting log loads normally. Append and repair still use ordinary file-handle fsyncs on both platforms. A failed append closes its append-only handle, reopens the log read/write, truncates it to the pre-append size, and fsyncs the rollback because Windows rejects `ftruncate` on append-only handles. diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index 0ed8f5d7a4..eacbd89847 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.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-22-slot-type-chain-implementation.md: 65b4ebb475fe34d71d8d3a08878b40103b3c95bd -2026-07-22-slot-type-chain-implementation.zh.md: 4c55171ca0118782568e17f349f83d6cf9211617 +2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8 +2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index 65b4ebb475..617524475f 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -42,7 +42,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child | Share | Type | Source of truth | Contents | |---|---|---|---| -| runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` | +| runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` | | child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` | | store | `PropsStore` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) | | business | `I` | inject return type | plain data + callbacks (hooks banned) | @@ -84,7 +84,7 @@ An inject factory takes what its declarations earn it — `sessionId` for sessio ### Data-boundary discipline -Hooks are framework-made only: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own. +Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own. ### Tree context and the renderer seam diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 4c55171ca0..52edea30ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -42,7 +42,7 @@ ctx.slots.register({ | 份额 | 类型 | 真源 | 内容 | |---|---|---|---| -| 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` | +| 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` | | 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | | 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) | @@ -84,7 +84,7 @@ inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`, ### 数据界线纪律 -hook 只许框架造:`useSession`、`useSessions`、`useStore`、`renderSlot` 是仅有的四席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 +hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 ### 树上语境与渲染器安装缝 diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml new file mode 100644 index 0000000000..040701d1d6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2 +2026-07-24-project-session-directories.zh.md: 3d8d33fa9fddad010ab319ac4e1f873b69b4e1dd diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md new file mode 100644 index 0000000000..0aa3f513d5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md @@ -0,0 +1,52 @@ +# Agent Note: Project-grouped session directories + +Status: implemented + +English | [中文](2026-07-24-project-session-directories.zh.md) + +## Problem + +A persistence root may be local to one project, shared by several projects, temporary, or centralized. The hashed cwd buckets kept all deployments functional but made a shared root difficult to navigate because a developer could not recognize a project from its directory name. + +Each JSONL session also occupied one file directly inside the project bucket. That shape had no ownership directory for additional session artifacts such as metadata, attachments, spill files, or coordination state. + +## Decision + +The JSONL backend stores sessions under a readable project key and gives every session its own directory: + +```text +/ + ----/ + / + session.jsonl.zstd +``` + +Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable name is bounded to keep the component within filesystem limits. + +The project key intentionally has no hash suffix. This follows the common human-readable convention used by coding agents and keeps the normalized project path as the complete directory name. The normalization is lossy: paths such as `/a/b-c` and `/a-b/c`, or long paths with the same retained prefix, share one project directory. Their distinct session ids still select separate session directories; reuse of the same session id remains a storage collision and is rejected. + +Case-insensitive filesystems can also make differently cased project keys refer to one physical directory. Identity validation accepts such an alternate spelling only when filesystem canonicalization resolves the discovered and expected paths to the same transcript. A different canonical path remains corruption, so case aliases do not weaken the same-id collision check on case-sensitive stores. + +The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure. + +The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change. + +Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `/.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration. + +## Alternatives considered + +**Keep opaque cwd hashes.** This preserved short names but defeated the requested navigation by project path when several projects share a persistence root. + +**Put session files directly in each project directory.** This matched Claude Code and pi's basic file organization but left no session-level ownership boundary for future artifacts. + +**Add a collision-resistant hash suffix.** This distinguishes paths whose normalized forms collide, but makes the directory name more than the normalized project path. The chosen convention accepts lossy project grouping in exchange for the simpler, recognizable name. + +**Mandate a centralized root.** Rejected because storage placement belongs to deployment configuration. Project grouping is useful when roots are shared and harmless when they are not. + +**Load both flat and directory layouts.** Rejected under the pre-release no-compatibility stance. One accepted layout keeps identity checks and discovery deterministic. + +## Consequences + +Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every session has a directory available for future backend-owned artifacts, and existing transcript consumers still receive a file path. + +Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix. Moving a project usually selects a different directory, but distinct cwd strings that normalize to the same name share one project directory by design. diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md new file mode 100644 index 0000000000..3d8d33fa9f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 按项目分组的会话目录 + +Status: implemented + +[English](2026-07-24-project-session-directories.md) | 中文 + +## 问题 + +持久化根目录可以只供一个项目使用,也可以由多个项目共享,还可以是临时目录或集中式目录。对 cwd 进行哈希得到的分桶目录能适用于所有这些部署方式,但开发者无法从目录名辨认项目,因此共享根目录难以浏览。 + +每个 JSONL 会话也直接以单个文件的形式放在项目分桶目录中。这种布局没有为元数据、附件、溢写文件或协调状态等其他会话产物提供归属目录。 + +## 决策 + +JSONL 后端按可读的项目键存储会话,并为每个会话提供独立目录: + +```text +/ + ----/ + / + session.jsonl.zstd +``` + +原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读名称则限制长度,以确保目录项不超过文件系统限制。 + +项目键有意不带哈希后缀。这遵循 coding agent(编码智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。 + +在不区分大小写的文件系统上,大小写不同的项目键也可能指向同一个物理目录。只有当文件系统路径规范化将发现路径和预期路径解析为同一个 transcript(文本记录)时,身份验证才接受这种拼写变体。规范化后的路径如果不同,仍视为存储损坏,因此大小写别名不会让区分大小写的存储放宽同一 id 的冲突检查。 + +根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。 + +编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path` 和 `DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。 + +延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O,首次追加会先创建项目目录和会话目录,再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `/.jsonl*` 产物;预发布格式不提供自动数据迁移。 + +## 考虑过的替代方案 + +**保留不透明的 cwd 哈希。** 这可以保持目录名简短,但当多个项目共享一个持久化根目录时,无法满足按项目路径浏览的需求。 + +**把会话文件直接放入各项目目录。** 这与 Claude Code 和 pi 的基本文件组织一致,但没有为未来产物提供会话级归属边界。 + +**添加防冲突的哈希后缀。** 这种方式能区分规范化形式相同的路径,但会使目录名不再只是规范化后的项目路径。所选约定接受有损的项目分组,以换取更简单、易于辨认的名称。 + +**强制使用集中式根目录。** 不予采纳,因为存储位置属于部署配置。项目分组在根目录共享时有用,在不共享时也没有负面影响。 + +**同时加载扁平布局和目录布局。** 按照预发布阶段不提供兼容性的原则,不予采纳。只接受一种布局,可以让身份检查和发现过程保持确定性。 + +## 后果 + +共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个会话都有一个可供后端未来存放自有产物的目录,而现有 transcript 消费方仍会收到文件路径。 + +项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀。移动项目通常会选择不同的目录,但按设计,不同的 cwd 字符串如果规范化成相同名称,就会共用同一个项目目录。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 795cb97082..b5945b5342 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa +2026-07-24-web-config-tree-boot-and-transport-layering.md: 377ebd2b3cf9ff1dff81dd3546bb262e0ebde88a +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 403e95fb3088d2164d50710d2a69de5526807764 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 9e93b828d5..377ebd2b3c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -18,14 +18,14 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. -**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. ## Consequences - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted. -- Headless still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger. +- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals. - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 996a5705bd..403e95fb30 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -18,14 +18,14 @@ Status: implemented **每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 -**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从 runtime 迁入(依赖方向允许;runtime 保留 `bootHost`/`startHost` 供 headless)。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 +**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 自已退役的 runtime 包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设特设子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`。 ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- headless 本轮仍走 `bootHost`;它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。 +- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml index f907cf276b..7782ea3360 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.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-20-jsonl-storage-identity.md: 1ada16791f411a54fbcf9271c7d7963223bbe683 -2026-07-20-jsonl-storage-identity.zh.md: 8027c51dbf6c7d01463b7851d859a40890bf03e1 +2026-07-20-jsonl-storage-identity.md: 1079eb700c819951dbb81e99376c0b71e3e84617 +2026-07-20-jsonl-storage-identity.zh.md: d7ba5c646a7adaaa0ebd60fac7b9c2f030361ff9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md index 1ada16791f..1079eb700c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md @@ -6,11 +6,11 @@ English | [中文](2026-07-20-jsonl-storage-identity.zh.md) ## Problem -JSONL lookup selects a physical log from the requested session id across cwd buckets, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The bucket scan also needs a defined result when the same encoded id exists in more than one bucket. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id. +JSONL lookup selects a physical log from the requested session id across project directories, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The project scan also needs a defined result when the same encoded id exists in more than one project directory. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id. ## Decision -`loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every cwd bucket, requires at most one matching encoded filename, parses that file, then validates both `header.id === id` and `selectedPath === logPath(root, header.cwd, header.id)` before returning metadata. `list()` applies the same path validation and rejects duplicate ids across buckets. +`loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every project directory, requires at most one matching encoded session directory with a transcript, parses that file, then validates `header.id === id` and that the selected path either equals `logPath(root, header.cwd, header.id)` or filesystem canonicalization resolves both spellings to the same transcript. `list()` applies the same path validation and rejects duplicate ids across project directories. The coordinator independently asserts the returned id and compares the stored cwd with a live session's cwd before repair, state publication, or suffix persistence. It keeps a detached copy of validated metadata; JSONL append and repair derive their path from that copy. The `PersistenceBackend` interface therefore needs neither a scope-specific live lookup nor a storage-locator type. @@ -18,7 +18,7 @@ An existing configured JSONL root must be a readable directory when the plugin l ## Alternatives considered -**Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without changing the project-grouped cwd layout or its consumers. +**Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without making the check depend on a flat global namespace. **Carry an opaque storage locator through the coordinator.** A locator binds JSONL mutations directly to a selected path, but JSONL can reproduce that path from metadata it has already validated. Adding another generic and argument to SQLite, test backends, append, and repair makes every implementation carry a concept only the file backend needs. @@ -26,4 +26,4 @@ An existing configured JSONL root must be a readable directory when the plugin l ## Consequences -Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. The cwd-bucket format stays unchanged and needs no migration. Lookup remains proportional to the number of buckets, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, cwd collision handling, and load-time root validation. +Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. Lookup remains proportional to the number of project directories, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, normalized-project collisions and case aliases, and load-time root validation. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md index 8027c51dbf..d7ba5c646a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个分桶目录中时,分桶扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。 +JSONL 查找会根据请求的会话 id 在各个项目目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个项目目录中时,项目扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。 ## 决策 -`loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有 cwd 分桶目录,要求匹配编码文件名的日志至多有一个,解析该文件,然后在返回元数据前同时验证 `header.id === id` 和 `selectedPath === logPath(root, header.cwd, header.id)`。`list()` 执行相同的路径验证,并拒绝跨分桶目录重复的 id。 +`loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有项目目录,要求名称与该 id 的编码值匹配且其中包含 transcript(文本记录)的会话目录至多有一个,解析其中的 transcript,然后验证 `header.id === id`,并验证选定路径要么等于 `logPath(root, header.cwd, header.id)`,要么经文件系统路径规范化后,两种写法解析为同一份 transcript。`list()` 执行相同的路径验证,并拒绝跨项目目录重复的 id。 协调器会独立断言返回的 id,并在修复、发布状态或持久化后缀之前比较已存 cwd 和活动会话的 cwd。协调器保留一份已验证元数据的独立副本;JSONL 的追加和修复操作根据该副本派生路径。因此,`PersistenceBackend` 接口既不需要限定范围的活动会话查找,也不需要存储定位器类型。 @@ -18,7 +18,7 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物 ## 考虑过的替代方案 -**按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需改变按项目分组的 cwd 布局及其消费方,也能消除身份缺陷。 +**按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需让检查依赖扁平的全局命名空间,也能消除身份缺陷。 **通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为 SQLite、测试后端、追加和修复操作增加一个泛型和参数,会让每个实现都承担只有文件后端需要的概念。 @@ -26,4 +26,4 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物 ## 后果 -JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。cwd 分桶格式保持不变,无需迁移。查找开销仍与分桶目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、cwd 冲突处理以及加载时的根目录验证。 +JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。查找开销仍与项目目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、项目路径规范化冲突与大小写别名,以及加载时的根目录验证。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml new file mode 100644 index 0000000000..4267e3b83f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-empty-model-response-is-retryable.md: f4a6373178efd5ca1ba5882fb2aaf97dffb2526b +2026-07-24-empty-model-response-is-retryable.zh.md: 4c3afe44140c029d274f34ade97803b958c6d669 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md new file mode 100644 index 0000000000..f4a6373178 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md @@ -0,0 +1,36 @@ +# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures + +Status: implemented + +English | [中文](2026-07-24-empty-model-response-is-retryable.zh.md) + +## Problem + +Providers occasionally return a degenerate completion: a well-formed stream that carries a terminal `stop` finish and zero content blocks — no text, no reasoning, no tool calls. If an adapter maps this shape to a successful `{kind: 'stop'}` finish, the loop logs an empty `assistant/message` and ends the turn as `completed`. Retry never runs, no failure reaches the caller, and a driver such as goal-session consumes a round without progress. + +## Decision + +An adapter classifies a completed empty response as a provider-boundary failure, and retry policy treats it as transient: + +- `dsh-llm` exports the canonical code `EMPTY_RESPONSE_CODE` (`'EMPTY_RESPONSE'`) beside `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE`. +- `dsh-llm-pi-ai` (`mapStopReason`): a terminal `stop` whose assistant message has no content blocks becomes a `finish {kind: 'error'}` with that code. Context-overflow detection still wins where it applies (it is checked first and is the more actionable classification). +- `dsh-llm-deepseek` (`translate`): at `[DONE]`, a `stop` (or absent) finish with no opened blocks becomes the same error finish. Reasoning-only streams count as content and stay successful. +- `dsh-llm-retry` adds `EMPTY_RESPONSE` to `DEFAULT_RETRYABLE_CODES`: the attempt produced nothing durable, so repeating it is safe; deployments can still remove it via `retryableCodes`. + +Detection is scoped to `stop` finishes only. `max-tokens` with empty content keeps its existing meaning (pi-ai already normalizes the zero-output overflow case), `tool-calls` cannot be block-empty in practice, and error/aborted finishes already fail. + +The classification uses the existing loop machinery — `finishError` → `agent/request-error` → `dsh-llm-retry` — and keeps `agent-loop` provider-neutral. Exhausting the retry budget ends the turn with an explicit `EMPTY_RESPONSE` failure instead of an empty success. + +## Alternatives considered + +**Detect in the loop or `BlockAssembler`.** One shared implementation, but it moves provider-response judgment into the loop, against "plugins, not loop changes", and the assembler is a pure assembly algorithm. The adapter is where wire facts become harness classification, with the overflow reclassification as exact precedent. + +**A stream-transform plugin on the `llm/stream` waterfall.** Provider-neutral and one implementation, but it adds a package plus wiring for what is a boundary fact each adapter can state in a few lines, and default-on behavior would still require touching every bundle. + +**Treat whitespace-only or reasoning-only responses as empty too.** Rejected as overreach: those carry model-produced content, and misclassifying a legitimate (if useless) response as a transport-class failure risks retry loops on models that intentionally stop after reasoning. The scope is exactly "zero content blocks". + +## Consequences + +- A transiently misbehaving provider consumes a bounded retry instead of a turn with no output; a persistently empty model surfaces an actionable `EMPTY_RESPONSE` turn failure. +- A model that genuinely intends to say nothing (rare, but possible after a tool result) is retried and, if consistently empty, fails the turn. This trade was accepted deliberately: an empty assistant message is indistinguishable from the provider defect and has no value to the user. +- The `empty-response-retry` ACP snapshot (an authored keyless scenario with a deterministic 1 ms zero-jitter retry overlay, `examples/acp-agent/retry.cordis.yml`) pins the product-visible behavior: a durable `llm/retry` event, no ACP output for the discarded attempt, the recovered reply, and a clean completed turn. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md new file mode 100644 index 0000000000..4c3afe4414 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md @@ -0,0 +1,36 @@ +# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures + +Status: implemented + +[English](2026-07-24-empty-model-response-is-retryable.md) | 中文 + +## Problem + +提供方偶尔会返回一种退化的 completion:流本身格式完好,携带一个终止性的 `stop` 结束,却没有任何内容块——没有文本、没有 reasoning(推理)、没有工具调用。如果适配器把这种形态映射为成功的 `{kind: 'stop'}` 结束,主循环就会记录一条空的 `assistant/message`,并把该轮次以 `completed` 结束。系统不会重试,失败也不会向调用方暴露,而像 goal-session 这样的驱动方会消耗一个轮次,却没有取得任何进展。 + +## Decision + +由适配器把「已完成但为空」的响应归类为一次提供方边界失败,重试策略则将其视为瞬时性问题: + +- `dsh-llm` 在 `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE` 之外,导出规范代码 `EMPTY_RESPONSE_CODE`(`'EMPTY_RESPONSE'`)。 +- `dsh-llm-pi-ai`(`mapStopReason`):当终止性 `stop` 所对应的 assistant 消息没有内容块时,它会变成一个携带该代码的 `finish {kind: 'error'}`。上下文溢出检测在其适用场景中仍然优先(它先被检查,也是更具可操作性的归类)。 +- `dsh-llm-deepseek`(`translate`):在 `[DONE]` 处,若 `stop`(或缺失)结束且没有打开过任何块,则同样变成该错误结束。仅含 reasoning 的流算作有内容,仍视为成功。 +- `dsh-llm-retry` 把 `EMPTY_RESPONSE` 加入 `DEFAULT_RETRYABLE_CODES`:这次尝试没有产生任何持久内容,因此重复它是安全的;部署方仍可通过 `retryableCodes` 将其移除。 + +检测仅限于 `stop` 结束。内容为空的 `max-tokens` 保持其既有含义(pi-ai 已经把零输出的溢出场景归一化处理),`tool-calls` 在实践中不可能是空块,而 error/aborted 结束本身已经算失败。 + +这套归类使用既有的主循环机制——`finishError` → `agent/request-error` → `dsh-llm-retry`——并让 `agent-loop` 保持提供方无关。重试预算耗尽时,该轮次会以显式的 `EMPTY_RESPONSE` 失败结束,而不是在没有内容的情况下成功结束。 + +## Alternatives considered + +**在主循环或 `BlockAssembler` 中检测。** 只需一份共享实现,但这会把对提供方响应的判断挪进主循环,违背「插件优先,而非改动主循环」,且 assembler 是纯粹的组装算法。适配器才是把协议层面的事实转化为 harness 归类的地方,而溢出重归类正是精确的先例。 + +**在 `llm/stream` waterfall(瀑布式事件)上做一个流转换插件。** 这种做法提供方无关且只需一份实现,但它为「每个适配器几行就能声明的边界事实」额外增加了一个包和相应接线,而且默认开启的行为仍需改动每一个 bundle。 + +**把仅含空白或仅含 reasoning 的响应也当作空响应。** 作为过度设计予以否决:这类响应携带了模型产生的内容,把一个合法(哪怕无用)的响应误判为传输类失败,会在那些故意在 reasoning 之后停止的模型上引发重试循环。其范围严格限定为「零内容块」。 + +## Consequences + +- 一个偶发异常的提供方会消耗一次有界重试,而不是一个没有输出的轮次;一个持续返回空内容的模型则会暴露为用户可据以行动的 `EMPTY_RESPONSE` 轮次失败。 +- 一个确实打算什么都不说的模型(罕见,但在一次工具结果之后有可能出现)会被重试,若始终为空,则该轮次失败。这个取舍是经过审慎权衡后接受的:一条空的 assistant 消息与提供方缺陷无法区分,且对用户毫无价值。 +- `empty-response-retry` ACP 快照(一个人工编写的无密钥场景,配有确定性的 1 ms 零抖动重试 overlay,`examples/acp-agent/retry.cordis.yml`)钉住了产品可见的行为:持久的 `llm/retry` 事件、被丢弃的尝试不产生任何 ACP 输出、恢复后的回复,以及一次干净的已完成轮次。 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml new file mode 100644 index 0000000000..3295a845f3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b +2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md new file mode 100644 index 0000000000..a02087235a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -0,0 +1,117 @@ +# Agent Note: Workspace UI Complete Product Flow + +Status: implemented + +English | [中文](2026-07-25-workspace-ui-product-flow.zh.md) + +## Problem + +[Domain KV Storage and the Workspace Entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) defines the persistent Workspace entity, path conventions, and ordered Session ledger, but not the Host wiring, historical-data initialization, or GUI flow. The GUI presents both Workspaces and Sessions; users must be able to type immediately after entering New Session, even when no Host Session or Host Workspace exists yet. + +Pending Workspaces, pending Sessions, retained input, and Host entity publication need clear owners and must preserve the same page identity when RPC completions and Host frames arrive in either order. Eagerly creating a Host Session for the zero state would bring a page with no input into the Host lifecycle. Historical Sessions also expose only the lightweight `SessionHeader.cwd` for grouping; initialization cannot read event bodies. + +## Decision + +### Host and persistent data + +The Host provides the following GUI wiring on the Workspace entity: + +| RPC | Behavior | +| --- | --- | +| `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | +| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | +| `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path | +| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | +| `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | + +`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. + +A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. + +The Workspace domain uses a durable marker to distinguish “never initialized” from “initialized but empty.” When the marker is absent, the Registry calls only `SessionPersistence.list()` to read header metadata; it calls neither `load` nor `inspect`, reads no history, and parses no event bodies. Valid cwd values are grouped by canonical path, and both Sessions within each group and the Workspace groups themselves are initialized in descending header `createdAt` order. Bootstrap is reentrant and writes the marker last; after the marker is written, new Sessions created without `workspaceId` are no longer adopted automatically. + +### Client object model + +`Session` and `Workspace` are frontend objects from the page Intent stage onward. + +- A frontend Session preallocates a SessionId when created and owns its Intent target and `pendingPrompt`; it remains the same Session object after Host `session.create` succeeds. +- Before materialization, a frontend Workspace has no WorkspaceId and owns its create input, phase, and error; after Host `workspace.create` succeeds, the same Workspace object adopts the returned view. +- `SessionManager` and `WorkspaceManager` own object indexes and merge Host baselines and deltas; the objects are the sole source of state for both Intents and Host views. +- `SessionsService` provides Session objects, real selection, scope, and list projections; `WorkspacesService` depends on `SessionsService` and owns the default Workspace, cross-object New Session flow, and Workspace materialization. + +A page has at most one frontend Session Intent and one accompanying Workspace Intent that exists only in the zero-Workspace state. Intents exist only on the current page and disappear on refresh; real Session selection can be restored persistently. Selecting a real Session or starting another Session Intent revokes the old Intent's eligibility for automatic sending, but does not roll back a Session already published by the Host or any accepted message. + +The Session owns the first input and drives one internal pipeline: when necessary, it attaches to a Workspace with its preallocated id, then sends `pendingPrompt`. Both attach and send failures return to the same Session. Workspace creation phase and error belong only to the Workspace object; the Session does not simulate the Workspace lifecycle. + +### User flow + +On initial entry, the application waits until both the Workspace and Session baselines are ready. It restores a real Session selection that remains valid; otherwise, it enters New Session and selects the most recent Workspace exactly once. The most recent Workspace is determined by the maximum `updatedAt` of its member Sessions, falling back to `createdAt` for an empty Workspace. This derived value chooses only the default target: it does not alter the Host Workspace order or trigger another selection after later hydration. + +When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order. + +Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. + +Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope. + +### First send and recovery + +A frontend Session's `pendingPrompt` retains its original text until the Host accepts the message. The first send advances through Workspace materialization, Session attachment, and prompt sending in order: + +1. If Workspace creation fails, the Workspace Intent retains its input and error, and the Session continues to target that object. +2. If Session creation fails before publication, the Session Intent returns to an editable state and retries with the same preallocated SessionId. +3. `workspace-attach-failed` proves that the Session has been published; the same Session object enters the real list and retains the prompt, and subsequent retries attach it. +4. If the prompt fails, the Session retains it and retries only send without recreating the Workspace or Session. +5. If the page switches to another Intent while a Session is being created, the old Session does not send automatically even if it is subsequently published; it retains its original prompt and visible error. + +Lost RPC responses, Host frames arriving before completions, and completions arriving before Host frames all converge through the preallocated SessionId and object identity. The Manager performs ordered upserts of Host views and prioritizes preserving the original object identity during local materialization, rather than creating a temporary second row with the same id. + +### Sidebar and ordering + +Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups. + +Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration. + +A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows. + +Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. + +### React and slot boundaries + +React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer. + +The Sidebar and conversation empty hero receive standardized actions through slots: `startSession`, `updateSessionPrompt`, `sendSession`, `open`, and `toggleSidebar`. The Workspace picker reuses the same component and the `createWorkspace` seam; its owner supplies only popover state, an anchor, and a selection callback. The presentation layer does not send `host/workspace-changed` directly; Host events originate only from Host mutations and the stream adapter. + +## Alternatives considered + +**Store separate page records for pending Workspaces and Sessions.** This approach must replace identities after materialization and hand off input, errors, focus, and sidebar rows; Intent state owned by the objects preserves identity continuity. + +**Let the presentation layer or root Zustand store orchestrate object lifecycles.** This approach duplicates Manager and Service responsibilities and brings domain state back into React. Runtime services provide standardized actions, while slots inject only the narrow interfaces required by presentation. + +**Immediately create a Host Session or Host persistence intent in the zero state.** A page with no input would enter the Host lifecycle and change refresh semantics; before the first send, the frontend Session retains only a page-local Intent. + +**Delay an explicit Create Workspace until the first send.** After confirmation, the sidebar would still show no real empty Workspace, conflating “create a Workspace” with “prepare a Session”; only the zero-Workspace Intent generated automatically by the system delays materialization. + +**Continuously derive Workspaces dynamically from cwd.** This cannot represent empty Workspaces, stable display names, or explicit ordering, and would automatically adopt non-Workspace callers; cwd is used only for one historical bootstrap and bidirectional membership validation. + +**Have the Client batch-reorder by time after the Session list arrives.** The initial screen would first show the Host order and then jump as a whole, and reconnecting could change positions again; the Host's persistent ledger owns ordering, while the Client merges only individual updates. + +**Add workspaceId to SessionHeader.** This would create two persistent ownership fields alongside the Workspace index and require double writes; the header retains the Session's own cwd fact, while the Workspace index owns explicit membership. + +## Verification + +- The zero state with no Workspace writes nothing to the Host and accepts input; explicit Create Workspace immediately creates and displays an empty Workspace. +- Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer. +- The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId. +- Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. +- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. +- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. +- Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. + +## Consequences + +- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward. +- Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point. +- Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract. +- Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace. +- Before its first event, a Host Session retains the existing lazy-persistence semantics; frontend Intents do not change empty-Session behavior after a Host restart. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md new file mode 100644 index 0000000000..8ccbf5b984 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -0,0 +1,117 @@ +# Agent Note: Workspace UI 完整产品动线 + +[English](2026-07-25-workspace-ui-product-flow.md) | 中文 + +Status: implemented + +## Problem + +[Domain KV storage 与 Workspace entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)定义了 Workspace 的持久实体、路径规范和有序 Session 账本,但没有定义 Host 接线、历史数据初始化或 GUI 动线。GUI 同时呈现 Workspace 和 Session;用户进入 New Session 后必须立即输入,即使此时还没有 Host Session,甚至没有 Host Workspace。 + +待创建 Workspace、待创建 Session、输入保留与 Host 实体发布必须具有明确所有者,并在 RPC completion 与 Host frame 以任意顺序到达时保持同一页面身份。若零态提前创建 Host Session,则无输入的页面状态会进入 Host 生命周期。历史 Session 又只有轻量 `SessionHeader.cwd` 可用于归组,初始化不能读取事件正文。 + +## Decision + +### Host 与持久数据 + +Host 在 Workspace entity 上提供以下 GUI 接线: + +| RPC | 行为 | +| --- | --- | +| `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | +| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | +| `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 | +| `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | +| `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | + +`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。 + +Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 + +Workspace domain 以 durable marker 区分“从未初始化”和“已初始化但为空”。marker 未设置时,Registry 只调用 `SessionPersistence.list()` 读取 header 元数据,不调用 `load`、`inspect`、history 或解析事件正文;有效 cwd 按 canonical path 分组,组内 Session 与 Workspace 组均按 header `createdAt` 降序初始化。Bootstrap 可重入,最后才写 marker;marker 写入后,绕过 `workspaceId` 的新 Session 不再被自动收编。 + +### Client 对象模型 + +`Session` 与 `Workspace` 从页面 Intent 阶段开始就是前端对象。 + +- 前端 Session 创建时预分配 SessionId,并在对象内持有 Intent target 与 `pendingPrompt`;Host `session.create` 成功后仍是同一个 Session 对象。 +- 前端 Workspace 在 materialize 前没有 WorkspaceId,并在对象内持有 create input、phase 与 error;Host `workspace.create` 成功后同一个 Workspace 对象 adopt 返回的 view。 +- `SessionManager` 与 `WorkspaceManager` 负责对象索引、Host 基线和增量合并;对象是 Intent 与 Host view 的唯一状态源。 +- `SessionsService` 提供 Session 对象、真实 selection、scope 与列表投影;`WorkspacesService` 依赖 `SessionsService`,负责默认 Workspace、跨对象 New Session 动线和 Workspace materialize。 + +页面至多有一个前端 Session Intent 和一个仅在零 Workspace 状态下配套的 Workspace Intent。Intent 只存在于当前页面,刷新后消失;真实 Session selection 可以持久恢复。选择真实 Session 或启动另一个 Session Intent 会放弃旧 Intent 的自动发送资格,但已经由 Host 发布的 Session 和已经接受的消息不会回滚。 + +Session 自己持有首条输入并驱动一条内部流水线:必要时以预分配 id attach 到 Workspace,然后发送 `pendingPrompt`。attach 与 send 的失败都落回同一 Session。Workspace 创建 phase/error 只属于 Workspace 对象,Session 不模拟 Workspace 生命周期。 + +### 用户动线 + +应用首次进入时等待 Workspace 与 Session 两份基线 ready。仍有效的真实 Session selection 被恢复;否则进入 New Session,并固定选择一次最近 Workspace。最近 Workspace 取其成员 Session 的最大 `updatedAt`,空 Workspace 回退到 `createdAt`;该派生只决定默认目标,不改变 Host Workspace 顺序,也不会在后续 hydration 时二次改选。 + +完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。 + +顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 + +Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编和显示名/目录名双输入不在本期范围。 + +### 首次发送与恢复 + +前端 Session 的 `pendingPrompt` 在 Host 接受消息前始终保留原文。首次发送按 Workspace materialize、Session attach、prompt send 顺序推进: + +1. Workspace 创建失败时,Workspace Intent 保留输入与错误,Session 仍指向该对象。 +2. Session 创建在发布前失败时,Session Intent 回到可编辑状态,以同一预分配 SessionId 重试。 +3. `workspace-attach-failed` 证明 Session 已发布;同一 Session 对象进入真实列表并保留 prompt,后续重试 attach。 +4. prompt 失败时,Session 保留 prompt 并只重试 send,不重复创建 Workspace 或 Session。 +5. Session 创建期间若页面切换到另一个 Intent,旧 Session 即使随后发布也不自动发送;它保留原 prompt 和可见错误。 + +RPC lost response、Host frame 先于 completion 和 completion 先于 Host frame 都通过预分配 SessionId 与对象身份收敛。Manager 对 Host view 做有序 upsert,本地 materialize 时优先保留原对象身份,不生成同 id 的临时第二行。 + +### Sidebar 与排序 + +Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位;Session 活跃不会移动 Workspace 组。 + +组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。 + +前端 Session Intent 只有在目标是真实 Workspace 时才作为 “New session” 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。 + +无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 + +### React 与 slot 边界 + +React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。 + +Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSession`、`updateSessionPrompt`、`sendSession`、`open` 与 `toggleSidebar`。Workspace picker 复用同一组件与 `createWorkspace` seam;owner 只提供 popover 开关、锚点和选中回调。呈现层不直接发送 `host/workspace-changed`,Host event 只由 Host mutation 与 stream adapter 产生。 + +## Alternatives considered + +**为待创建 Workspace 与 Session 保存独立页面记录。** 该方案在 materialize 后需要替换身份并转交输入、错误、焦点和 sidebar 行;对象自身的 Intent 状态可以保持身份连续。 + +**由呈现层或 root Zustand store 编排对象生命周期。** 该方案会重复 Manager/Service 的职责,并把领域状态带回 React。标准化动作由 runtime service 提供,slot 只注入呈现所需的窄接口。 + +**零态立即创建 Host Session 或 Host persistence intent。** 未输入页面会进入 Host 生命周期,并改变刷新语义;前端 Session 在首次发送前只保留 page-local Intent。 + +**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍看不到真实空 Workspace,“创建 Workspace”与“准备 Session”语义混合;只有系统自动产生的零 Workspace Intent 延迟 materialize。 + +**持续按 cwd 动态派生 Workspace。** 该方案无法表达空 Workspace、稳定显示名和显式顺序,也会自动收编非 Workspace 调用方;cwd 只用于一次历史 bootstrap 与成员双向校验。 + +**Client 在 Session list 到达后按时间批量重排。** 首屏会先展示 Host 顺序再整体跳动,重连也可能改变位置;排序由 Host 持久账本拥有,Client 只合并单项更新。 + +**在 SessionHeader 增加 workspaceId。** 它会与 Workspace 索引形成两个持久归属字段并要求双写;header 保留 Session 自身 cwd 事实,Workspace 索引负责显式归属。 + +## Verification + +- 完全无 Workspace 的零态不写 Host 且允许输入;显式 Create Workspace 立即创建并显示空 Workspace。 +- 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。 +- 首发按 Workspace、Session、prompt 顺序推进,各成功阶段不回滚,输入在 prompt 接受前不丢失,创建重试使用同一 SessionId。 +- Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 +- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 +- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 +- UI 与 Host 两层拒绝同名 Workspace;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 + +## Consequences + +- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。 +- 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。 +- 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 契约。 +- 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。 +- Host Session 在首个事件前仍遵循现有懒持久化语义;前端 Intent 不改变 Host 重启后的空 Session 行为。 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index d89275da06..65e49bd193 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-22-subagent-snapshot-replay.md: 6e5e94308ed145b83160146fd9e9ef023f2dde5d -2026-06-22-subagent-snapshot-replay.zh.md: 82bb7d0735c7dbf918941d00ee4c59498cc59085 +2026-06-22-subagent-snapshot-replay.md: 8cd7bc86e07af9ed274c18574b575b9070854e88 +2026-06-22-subagent-snapshot-replay.zh.md: eae78129405fedd03c2c579845c07c6e5694cc30 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md index 6e5e94308e..8cd7bc86e0 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -11,7 +11,7 @@ The snapshot tier (`pnpm run test:snapshot`) boots the real `acp-agent` subproce It was built for ONE session per process, and that assumption is wired into two places: - **`dsh-llm-replay` keyed nothing.** It served the Nth `llm/stream` call the Nth recorded entry from a single global cursor. With a parent agent AND an in-process subagent both streaming on one context, the calls interleave and the single cursor hands the child the parent's script (and vice versa). -- **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log in the same cwd bucket, so the child's transcript was silently dropped. +- **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log, so the child's transcript was silently dropped. This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam Agent Note](../feature/2026-06-21-subagent-capability-seam.md): the in-process backends (PR2) shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed. This Agent Note is that stacked follow-up. @@ -39,7 +39,7 @@ The alternative considered and rejected was a **call-ordered merge of the parent ### 3. The harness harvests every log, primary-first -`harvestSessionLogs` collects every `.jsonl` across every cwd bucket under the sessions root (the JSONL backend puts a parent and its same-cwd child in the same bucket), parses each header, and orders them primary-first: the top-level session (no `parentSession`) leads, then each child by ascending `createdAt`. `RunResult.sessionLogs` is the plural result; the spec writes each back to its fixture on record (`session.jsonl` + `session..jsonl`) and diffs each harvested log against its fixture on replay. The normalizer already accepted plural session ids and collapses any stray UUID, so no normalizer change was needed. +`harvestSessionLogs` recursively collects every fixed `session.jsonl` transcript under the sessions root (the JSONL backend gives each parent and child its own project/session directory), parses each header, and orders them primary-first: the top-level session (no `parentSession`) leads, then each child by ascending `createdAt`. `RunResult.sessionLogs` is the plural result; the spec writes each back to its fixture on record (`session.jsonl` + `session..jsonl`) and diffs each harvested log against its fixture on replay. The normalizer already accepted plural session ids and collapses any stray UUID, so no normalizer change was needed. ### 4. Scenarios diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index 82bb7d0735..eae7812940 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -11,7 +11,7 @@ Status: implemented 该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处: - **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent(智能体)和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent(反之亦然)。 -- **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行,在同一个 cwd bucket 下有自己的日志,因此子 agent 的 transcript(文本记录)被静默丢弃。 +- **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行并拥有自己的日志,因此子 agent 的 transcript(文本记录)被静默丢弃。 这就是 [subagent seam Agent Note(agent 决策记录)](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作:进程内后端(PR2)落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。 @@ -39,7 +39,7 @@ Status: implemented ### 3. harness 收集所有日志,主会话优先 -`harvestSessionLogs` 收集 sessions 根目录下每个 cwd bucket 中的所有 `.jsonl`(JSONL 后端将父会话与同 cwd 的子会话放在同一个 bucket),解析各自的 header,并按主会话优先排序:顶层会话(无 `parentSession`)在前,各子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果;spec 在录制时将每份日志写回对应 fixture(`session.jsonl` + `session..jsonl`),在回放时将每份收集到的日志与其 fixture 做 diff。归一化器已支持复数会话 id 并会折叠任何游离 UUID,因此无需修改归一化器。 +`harvestSessionLogs` 递归收集 sessions 根目录下所有固定命名为 `session.jsonl` 的 transcript(JSONL 后端为每个父会话和子会话分别提供独立的项目/会话目录),解析各自的 header,并按主会话优先排序:顶层会话(无 `parentSession`)在前,各子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果;spec 在录制时将每份日志写回对应 fixture(`session.jsonl` + `session..jsonl`),在回放时将每份收集到的日志与其 fixture 做 diff。归一化器已支持复数会话 id 并会折叠任何游离 UUID,因此无需修改归一化器。 ### 4. 场景 diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index faf234ae7c..37b070e466 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -7,6 +7,8 @@ description: Use when writing, reviewing, restoring, trimming, or auditing prose Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates. It is guidance, not a script. +Comments describe non-obvious contracts or rationale that code cannot express; they do not restate what code already implies. + ## Inputs and exclusions Require an explicit `scope`. If it is missing, report the required input and stop; do not infer a repository-wide scope or begin an interview. diff --git a/.gitignore b/.gitignore index ae9b4b5ddd..d6b400aeb3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log .cache/ examples/*/*.jsonl .sessions/ +.storages/ examples/*/.sessions/ coverage/ .doc-typecheck-*/ diff --git a/apps/cli/README.md b/apps/cli/README.md index 4172eca836..2afd9c346b 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -10,7 +10,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. The headless surface retains deterministic fallback titles without making the auxiliary title-model request. +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). ## Install (developer machine) diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index a85f2eaf2c..b89df77c46 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -72,6 +72,22 @@ config: root: './.sessions' +- id: storage + name: '@deepseek-ai/dsh-storage' + +- id: storage-json + name: '@deepseek-ai/dsh-storage-json' + config: + root: './.storages' + +- id: storage-domain + name: '@deepseek-ai/dsh-storage-domain' + config: + backend: json + +- id: workspace + name: '@deepseek-ai/dsh-workspace' + - id: bash-local name: '@deepseek-ai/dsh-bash-local' @@ -217,6 +233,9 @@ - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' +- id: ui-workspace + name: '@deepseek-ai/dsh-client-ui-workspace' + - id: ui-question name: '@deepseek-ai/dsh-client-ui-question' diff --git a/apps/cli/package.json b/apps/cli/package.json index a39ce726bb..e1c07f90b5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,12 +32,12 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", @@ -50,6 +50,9 @@ "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", @@ -69,6 +72,7 @@ "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0" diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 52df4203b1..29e20b87b8 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,8 +1,8 @@ /** - * AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares - * (config-tree boot wired for `dsh web` this round; TUI/headless migrate - * later). Everything here is what must exist before the Loader runs: layered - * env, the patch composition over the shipped cordis.yml (profile json + CLI + * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share + * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later). + * Everything here is what must exist before the Loader runs: layered env, + * the patch composition over the shipped cordis.yml (profile json + CLI * flags + the resolved frontend dist), and the fail-loud triple after the * tree settles. */ @@ -62,22 +62,30 @@ const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) const FIBER_ACTIVE = 2 as FiberState.ACTIVE const FIBER_PENDING = 0 as FiberState.PENDING -/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */ +/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ export interface AppCLIEntryOptions { /** Absolute path of the shipped cordis.yml. */ configPath: string - /** Whether to append the HMR row (the whole prod/dev difference). */ + /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */ dev: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ host?: string - /** --port when explicitly passed; undefined keeps the yml engineering default. */ + /** + * Listen port override onto the webserver row. Web passes the --port flag + * value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs + * never collide — and the printed URL still opens the live session in a + * browser). + */ port?: number + /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ + workspaceRoot?: string } /** - * Boot driver for the config-tree `dsh web` shape: holds only what exists - * independently of (and prior to) cordis — argv facts, the composed patch - * set, and finally the root ctx. + * Boot driver for the config-tree dsh surfaces (web and headless share the + * one composition; the surfaces differ only in constructor facts): holds only + * what exists independently of (and prior to) cordis — argv facts, the + * composed patch set, and finally the root ctx. */ export class AppCLIEntry { /** The root context, set by {@link run}. */ @@ -99,13 +107,13 @@ export class AppCLIEntry { this.assertBoot() const port = this.ctx.get('httpServer')?.port /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */ - if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot') + if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot') return { ctx: this.ctx, port } } /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */ private loadEnvLayers(): void { - loadEnv('dsh web', resolveDshHome()) + loadEnv('dsh', resolveDshHome()) } /** @@ -127,7 +135,7 @@ export class AppCLIEntry { for (const [key, value] of Object.entries(this.readProfile())) { const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) if (mapping === undefined) { - throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) + throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) } put(mapping.entryId, mapping.configKey, value) } @@ -135,6 +143,7 @@ export class AppCLIEntry { // Source 2: CLI flags (field set disjoint from the json mappings). if (this.options.host !== undefined) put('webserver', 'host', this.options.host) if (this.options.port !== undefined) put('webserver', 'port', this.options.port) + if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) // Source 3: the frontend dist — an assembly fact of this app, never yml // user config. Workspace knowledge stays here. @@ -142,7 +151,7 @@ export class AppCLIEntry { this.patches = [...overrides.entries()].map(([id, bag]) => { const yml = rows.get(id) - if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`) + if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`) return { id, config: { ...(yml.config ?? {}) as Record, ...bag } } }) } @@ -173,8 +182,8 @@ export class AppCLIEntry { * below catches PENDING fibers (cordis inject waiting has no timeout). */ private assertBoot(): void { - installFailLoud('dsh web') - assertEntriesLoaded(this.ctx, 'dsh web') + installFailLoud('dsh') + assertEntriesLoaded(this.ctx, 'dsh') const failures: string[] = [] for (const entry of this.ctx.loader.entries()) { if (entry.fiber === undefined || entry.disabled) continue @@ -188,14 +197,14 @@ export class AppCLIEntry { } } if (failures.length > 0) { - throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) } } /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ private parseYmlRows(): Map { const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`) + if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`) const rows = new Map() for (const row of doc as { id?: string; config?: unknown }[]) { if (typeof row.id === 'string') rows.set(row.id, row) @@ -214,7 +223,7 @@ export class AppCLIEntry { } const parsed: unknown = JSON.parse(raw) if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) + throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) } return parsed as Record } @@ -225,7 +234,7 @@ export class AppCLIEntry { try { return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') } catch { - throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') + throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') } } } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 303bac61f8..7dceebaf47 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -1,18 +1,20 @@ /** - * `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic - * injection (InProcessApiClient over the host handler, so the full carrier - * chain — wire serialization, zod, SSE framing — really runs; this is the - * protocol's second real consumer). No HTTP server, no port, no dist - * resolution. Runs one task turn, prints the final assistant text, exits - * (completed → 0, else 1). + * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry + * boots the same cordis.yml as `dsh web` (port 0, so parallel runs never + * collide), then in-process isomorphic injection (InProcessApiClient over + * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire + * serialization, zod, SSE framing — really runs). The printed URL opens the + * live session in a browser while the task runs. Runs one task turn, prints + * the final assistant text, exits (completed → 0, else 1). */ import { parseArgs } from 'node:util' -import { startHost } from '@deepseek-ai/dsh-host-runtime' -import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy' +import { fileURLToPath } from 'node:url' +import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' +import { AppCLIEntry } from './app-cli-entry.ts' /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ interface TurnOutcome { @@ -78,15 +80,18 @@ export async function runHeadless(argv: string[]): Promise { } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ - boot: { - persistenceRoot: './.sessions', - workspaceContext: false, - }, + const entry = new AppCLIEntry({ + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + dev: false, + port: 0, }) - const api = new InProcessApiClient(host.handler) + const { ctx, port } = await entry.run() + const dispose = async (): Promise => { await ctx.fiber.dispose() } + // The headless session is web-observable while it runs (same composition). + process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) + const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) - const created = await unwrap(await api.sessions.create({}), () => host.dispose()) + const created = await unwrap(await api.sessions.create({}), dispose) // Open the stream before prompting so no frame is lost — kept in this order // even though in-process delivery has no race, so the code survives a move @@ -99,11 +104,11 @@ export async function runHeadless(argv: string[]): Promise { sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: task }], - }), () => host.dispose()) + }), dispose) const outcome = await done process.stdout.write(outcome.text + '\n') abort.abort() - await host.dispose() + await dispose() process.exit(outcome.reason === 'completed' ? 0 : 1) } diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index bcd1482df3..37e34cdf54 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -21,6 +21,7 @@ export async function runWeb(argv: string[]): Promise { host: { type: 'string' }, port: { type: 'string' }, dev: { type: 'boolean', default: false }, + 'workspace-root': { type: 'string' }, }, allowPositionals: false, }) @@ -44,6 +45,7 @@ export async function runWeb(argv: string[]): Promise { dev: values.dev, ...values.host !== undefined ? { host: values.host } : {}, ...port !== undefined ? { port } : {}, + ...values['workspace-root'] !== undefined ? { workspaceRoot: values['workspace-root'] } : {}, }) const { ctx, port: boundPort } = await entry.run() diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index b33280943a..4db4861b93 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../packages/host/apiproxy" }, - { - "path": "../../packages/host/runtime" - }, { "path": "../../packages/host/webserver" }, diff --git a/apps/web/src/node-module-stub.ts b/apps/web/src/node-module-stub.ts index c64f307f7c..0a9b04ea5f 100644 --- a/apps/web/src/node-module-stub.ts +++ b/apps/web/src/node-module-stub.ts @@ -1,10 +1,6 @@ /** - * Browser stand-in for `node:module`, mapped by the vite alias in - * vite.config.ts (design §2.4). The vendored Loader's internal.ts imports - * `createRequire` at module scope but only calls it inside - * `ModuleLoader.fromInternal()`, whose version probe is compiled to the - * `"0.0.0"` define in the browser build — so this throw is a fail-loud - * tripwire for any path that would genuinely need Node's module machinery. + * Browser stand-in for `node:module`. `createRequire` is unreachable in the + * configured loader path and fails loud if that assumption changes. */ /** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */ diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 050f2a39dc..d858e0f7ad 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -127,6 +127,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { beforeAll(async () => { scaffold = await launchWebScaffold({}) - // The read-tool targets exist in both modes: record needs them for the - // live turn; replay's seeded log carries their recorded contents but the - // workspace stays consistent for any user poking the scaffold. - await writeFile(join(scaffold.workspaceCwd, 'a.txt'), 'alpha\n') - await writeFile(join(scaffold.workspaceCwd, 'b.txt'), 'beta\n') + // The workspace-aware flow runs sessions in /workspace + // (the composer's default draft name); the read-tool targets must live in + // that session cwd. Pre-creating the directory is safe: create-by-name + // adopts an existing directory. + 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') if (MODE !== 'record') { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT]) diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index 673e92f9ce..c1616bb724 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -14,6 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] @@ -72,12 +73,12 @@ afterEach(() => { function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } { const tree = screen.getByRole('tree', { name: 'Sessions' }) const sidebar = within(tree).getByText(label).textContent ?? '' - const breadcrumb = within(screen.getByRole('navigation', { name: '会话层级' })) + const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' })) .getByRole('button', { name: label }).textContent ?? '' return { sidebar, breadcrumb, documentTitle: document.title } } -it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => { +it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => { const root = document.querySelector('#root') if (root === null) throw new Error('snapshot root missing') act(() => { @@ -92,8 +93,9 @@ it('projects initial and revised durable titles through the built eight-plugin f unmount = () => { entry.dispose() } }) - const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 }) - const projectRow = projectLabel.closest('[role="treeitem"]') + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const projectCount = await within(tree).findByText('4 sessions') + const projectRow = projectCount.closest('[role="treeitem"]') if (projectRow === null) throw new Error('fixture project row missing') fireEvent.click(projectRow) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 9cd34530be..a3d511df16 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -333,10 +333,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.` await input.fill(prompt) await input.press('Enter') - // startSession chain: session mounts, composer moves to the bottom. - // Regression pin (P0, 585671106): this send used to white-screen the tree - // (scope tag lost to a duplicate inlined runtime instance) — body going - // near-empty here means that class of bug is back. + // The first send must keep the session tree mounted; a near-empty body + // reveals a duplicate runtime bundle with incompatible scope tags. await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 }) expect(pageErrors).toEqual([]) await page.waitForFunction( diff --git a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl index 59489ab99b..9bd1959879 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl +++ b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl @@ -1,99 +1,95 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784959629995,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":1784959630019,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"f1126e16-eb24-483f-afa3-c247a325a739"}}}} -{"type":"user/message","seq":1,"time":1784959630019,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"f1126e16-eb24-483f-afa3-c247a325a739"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784959630021,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1784959630081,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1784959630082,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1784959630551,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1784959630551,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1784959630680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1784959630710,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1784959630711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1784959630711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1784959630711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":12,"time":1784959630711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":13,"time":1784959630740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":14,"time":1784959630740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":15,"time":1784959630741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":16,"time":1784959630741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":17,"time":1784959630770,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":18,"time":1784959630771,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":19,"time":1784959630771,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":20,"time":1784959630771,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":21,"time":1784959630800,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":22,"time":1784959630800,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":23,"time":1784959630800,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":24,"time":1784959630887,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":25,"time":1784959630888,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":26,"time":1784959630888,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":27,"time":1784959630888,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":28,"time":1784959630915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":29,"time":1784959630916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":30,"time":1784959630916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":31,"time":1784959630916,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":32,"time":1784959630941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":33,"time":1784959630942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":" WEB"}}} -{"type":"assistant/chunk","seq":34,"time":1784959630942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"_E"}}} -{"type":"assistant/chunk","seq":35,"time":1784959630942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":36,"time":1784959630942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":37,"time":1784959630942,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":38,"time":1784959630974,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1784959631006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":40,"time":1784959631006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1784959631007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":42,"time":1784959631007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1784959631007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":44,"time":1784959631027,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1784959631028,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":46,"time":1784959631028,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":47,"time":1784959631057,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":" test"}}} -{"type":"assistant/chunk","seq":48,"time":1784959631084,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":" string"}}} -{"type":"assistant/chunk","seq":49,"time":1784959631085,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":50,"time":1784959631114,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":51,"time":1784959631144,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command and then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":52,"time":1784959631144,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo test string\"}"}}}} -{"type":"assistant/chunk","seq":53,"time":1784959631144,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":54,"time":1784959631144,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1784959631148,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo test string\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":18}},"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],"surfaceOp":"append"} -{"type":"tool/call","seq":56,"time":1784959631149,"data":{"turn":1,"step":1,"callId":"call_00_QLU7wxjCnxqQC9T3SMi71047","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo test string\"}"}} -{"type":"tool/result","seq":57,"time":1784959631163,"data":{"turn":1,"step":1,"callId":"call_00_QLU7wxjCnxqQC9T3SMi71047","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"step/end","seq":58,"time":1784959631165,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":59,"time":1784959631166,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":60,"time":1784959631507,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":61,"time":1784959631507,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":62,"time":1784959631639,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":63,"time":1784959631669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":64,"time":1784959631697,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":65,"time":1784959631727,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WEB"}}} -{"type":"assistant/chunk","seq":66,"time":1784959631727,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_E"}}} -{"type":"assistant/chunk","seq":67,"time":1784959631727,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":68,"time":1784959631728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"E"}}} -{"type":"assistant/chunk","seq":69,"time":1784959631728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":70,"time":1784959631728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":71,"time":1784959631761,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":72,"time":1784959631762,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}} -{"type":"assistant/chunk","seq":73,"time":1784959631762,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":74,"time":1784959631762,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":75,"time":1784959631762,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":76,"time":1784959631762,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":77,"time":1784959631785,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":78,"time":1784959631786,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":79,"time":1784959631786,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":80,"time":1784959631786,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":81,"time":1784959631786,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":82,"time":1784959631845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":83,"time":1784959631845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":84,"time":1784959631845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":85,"time":1784959631845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":86,"time":1784959631845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":87,"time":1784959631846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":88,"time":1784959631851,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":89,"time":1784959631851,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":90,"time":1784959631851,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":91,"time":1784959631852,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command output \"WEB_E2E_OK\" as expected. Now I need to reply with just the single word \"DONE\"."}}}} -{"type":"assistant/chunk","seq":92,"time":1784959631852,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":93,"time":1784959631852,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":30,"cacheReadTokens":7808,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":94,"time":1784959631852,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":95,"time":1784959631853,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command output \"WEB_E2E_OK\" as expected. Now I need to reply with just the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":30,"cacheReadTokens":7808,"reasoningTokens":27}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":96,"time":1784959631854,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":97,"time":1784959631854,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"c4e068dc-c277-46ae-9713-6a2694027fb5"}}}} +{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"c4e068dc-c277-46ae-9713-6a2694027fb5"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784973850888,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1784973850889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1784973851088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":12,"time":1784973851107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":13,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} +{"type":"assistant/chunk","seq":14,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":15,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":16,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":17,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":18,"time":1784973851135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1784973851135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":21,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":22,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":23,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":24,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":25,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":26,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":28,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1784973851245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":30,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":32,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" WEB"}}} +{"type":"assistant/chunk","seq":33,"time":1784973851272,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"_E"}}} +{"type":"assistant/chunk","seq":34,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":35,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":36,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":37,"time":1784973851300,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":38,"time":1784973851326,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":39,"time":1784973851326,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1784973851352,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":41,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":43,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1784973851379,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":45,"time":1784973851406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":46,"time":1784973851406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":47,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" test"}}} +{"type":"assistant/chunk","seq":48,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" string"}}} +{"type":"assistant/chunk","seq":49,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1784973851461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":51,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}} +{"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":54,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":56,"time":1784973851499,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}} +{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":61,"time":1784973852195,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":62,"time":1784973852309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":63,"time":1784973852338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" executed"}}} +{"type":"assistant/chunk","seq":64,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":65,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":66,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":67,"time":1784973852370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":68,"time":1784973852370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WEB"}}} +{"type":"assistant/chunk","seq":69,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_E"}}} +{"type":"assistant/chunk","seq":70,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":71,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"E"}}} +{"type":"assistant/chunk","seq":72,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":73,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":74,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":75,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":76,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":77,"time":1784973852428,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":78,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":81,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":82,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":83,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":84,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":85,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":86,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":87,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":88,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":89,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":90,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":92,"time":1784973852461,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":93,"time":1784973852462,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 4b9a4123b6..a6d1203d9d 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -1,5 +1,5 @@ - banner: - - navigation "会话层级": + - navigation "Session hierarchy": - button "Use the bash tool to" [disabled] - text: · 1 turns - tablist: @@ -7,17 +7,17 @@ - tab "Trajectory" - tab "Waterfall" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop." -- button "Think The user wants me to run a specific bash command and then reply with \"DONE\".": +- button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - - text: Think The user wants me to run a specific bash command and then reply with "DONE". -- text: Echo test string -- button "Think The command output \"WEB_E2E_OK\" as expected. Now I need to reply with just the single word \"DONE\".": + - text: Think The user wants me to run a simple bash command and reply with "DONE". +- text: Echo the test string +- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": - img - - text: Think The command output "WEB_E2E_OK" as expected. Now I need to reply with just the single word "DONE". + - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". - paragraph: DONE -- text: cache hit 99% · 15,822 tokens · 1 turns · 2 steps -- textbox "输入消息,Enter 发送,Shift+Enter 换行" -- button "添加": +- text: cache hit 99% · 15,818 tokens · 1 turns · 2 steps +- textbox "Message the agent" +- button "Add attachment": - img - combobox "Plan mode": - option "Plan" [selected] @@ -28,4 +28,4 @@ - combobox "Model": - option "DeepSeek-V4-Pro High" [selected] - option "DeepSeek-V4-Pro" -- button "发送" [disabled] +- button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/seeded-history/seed.jsonl b/apps/web/tests/snapshots/seeded-history/seed.jsonl index a781b1389b..0f61158a54 100644 --- a/apps/web/tests/snapshots/seeded-history/seed.jsonl +++ b/apps/web/tests/snapshots/seeded-history/seed.jsonl @@ -1,116 +1,112 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784959659982,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":1784959659995,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"11e49fc6-9f3e-4570-aef3-21677733cf23"}}}} -{"type":"user/message","seq":1,"time":1784959659995,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"11e49fc6-9f3e-4570-aef3-21677733cf23"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784959659997,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1784959660056,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1784959660057,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1784959660422,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1784959660422,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1784959660513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1784959660538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1784959660539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1784959660539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1784959660539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":12,"time":1784959660539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":13,"time":1784959660565,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}} -{"type":"assistant/chunk","seq":14,"time":1784959660566,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":15,"time":1784959660593,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"a"}}} -{"type":"assistant/chunk","seq":16,"time":1784959660594,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":17,"time":1784959660594,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":18,"time":1784959660594,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}} -{"type":"assistant/chunk","seq":19,"time":1784959660594,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":20,"time":1784959660594,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} -{"type":"assistant/chunk","seq":21,"time":1784959660619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":22,"time":1784959660620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":23,"time":1784959660620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":24,"time":1784959660648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":25,"time":1784959660648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":26,"time":1784959660648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":27,"time":1784959660649,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":28,"time":1784959660674,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":29,"time":1784959660674,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":30,"time":1784959660675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":31,"time":1784959660675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":32,"time":1784959660675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} -{"type":"assistant/chunk","seq":33,"time":1784959660704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}} -{"type":"assistant/chunk","seq":34,"time":1784959660704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simultaneously"}}} -{"type":"assistant/chunk","seq":35,"time":1784959660704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":36,"time":1784959660782,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":37,"time":1784959660782,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":38,"time":1784959660783,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":39,"time":1784959660783,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":40,"time":1784959660808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":41,"time":1784959660809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":42,"time":1784959660809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1784959660809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":44,"time":1784959660834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1784959660835,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":"a"}}} -{"type":"assistant/chunk","seq":46,"time":1784959660835,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":47,"time":1784959660835,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":48,"time":1784959660869,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":49,"time":1784959660917,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":50,"time":1784959660918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":51,"time":1784959660944,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":52,"time":1784959660944,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1784959660944,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":54,"time":1784959660944,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":55,"time":1784959660944,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1784959660945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":57,"time":1784959660970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1784959660970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":"b"}}} -{"type":"assistant/chunk","seq":59,"time":1784959660970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":60,"time":1784959660970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":61,"time":1784959660997,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":62,"time":1784959661054,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read two files (a.txt and b.txt) and then reply with \"DONE\". Let me read both files simultaneously."}}}} -{"type":"assistant/chunk","seq":63,"time":1784959661054,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}} -{"type":"assistant/chunk","seq":64,"time":1784959661054,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}} -{"type":"assistant/chunk","seq":65,"time":1784959661054,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":106,"cacheReadTokens":7680,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":66,"time":1784959661054,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":67,"time":1784959661058,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read two files (a.txt and b.txt) and then reply with \"DONE\". Let me read both files simultaneously."},{"type":"tool-call","id":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":106,"cacheReadTokens":7680,"reasoningTokens":30}},"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],"surfaceOp":"append"} -{"type":"tool/call","seq":68,"time":1784959661059,"data":{"turn":1,"step":1,"callId":"call_00_PEK9aIA8oMzElfTpENlC4855","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}} -{"type":"tool/call","seq":69,"time":1784959661061,"data":{"turn":1,"step":1,"callId":"call_01_WPiM0aANEzq64FXGm8ra0024","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}} -{"type":"tool/result","seq":70,"time":1784959661065,"data":{"turn":1,"step":1,"callId":"call_00_PEK9aIA8oMzElfTpENlC4855","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[68],"surfaceOp":"append"} -{"type":"tool/result","seq":71,"time":1784959661066,"data":{"turn":1,"step":1,"callId":"call_01_WPiM0aANEzq64FXGm8ra0024","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[69],"surfaceOp":"append"} -{"type":"step/end","seq":72,"time":1784959661068,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":73,"time":1784959661069,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":74,"time":1784959661540,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":75,"time":1784959661540,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} -{"type":"assistant/chunk","seq":76,"time":1784959661644,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}} -{"type":"assistant/chunk","seq":77,"time":1784959661671,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} -{"type":"assistant/chunk","seq":78,"time":1784959661671,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} -{"type":"assistant/chunk","seq":79,"time":1784959661672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":80,"time":1784959661672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":81,"time":1784959661672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":82,"time":1784959661698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":83,"time":1784959661699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} -{"type":"assistant/chunk","seq":84,"time":1784959661699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":85,"time":1784959661699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"alpha"}}} -{"type":"assistant/chunk","seq":86,"time":1784959661699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":87,"time":1784959661728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":88,"time":1784959661729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}} -{"type":"assistant/chunk","seq":89,"time":1784959661729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":90,"time":1784959661729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} -{"type":"assistant/chunk","seq":91,"time":1784959661729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":92,"time":1784959661729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"beta"}}} -{"type":"assistant/chunk","seq":93,"time":1784959661753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":94,"time":1784959661753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":95,"time":1784959661753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":96,"time":1784959661753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":97,"time":1784959661780,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":98,"time":1784959661781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":99,"time":1784959661781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":100,"time":1784959661781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":101,"time":1784959661781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":102,"time":1784959661781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":103,"time":1784959661808,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":104,"time":1784959661808,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":105,"time":1784959661808,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":106,"time":1784959661809,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":107,"time":1784959661809,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":108,"time":1784959661809,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll reply with \"DONE\" as instructed."}}}} -{"type":"assistant/chunk","seq":109,"time":1784959661809,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":110,"time":1784959661809,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":216,"outputTokens":33,"cacheReadTokens":7808,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":111,"time":1784959661809,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":112,"time":1784959661810,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll reply with \"DONE\" as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":216,"outputTokens":33,"cacheReadTokens":7808,"reasoningTokens":30}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":113,"time":1784959661811,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":114,"time":1784959661811,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"f95c6f1c-f1b4-42bf-ba40-c05ae0647a70"}}}} +{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"f95c6f1c-f1b4-42bf-ba40-c05ae0647a70"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784974101296,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1784974101297,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1784974101422,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1784974101452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1784974101452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":12,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":13,"time":1784974101483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":14,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":15,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}} +{"type":"assistant/chunk","seq":16,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":17,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":18,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":19,"time":1784974101514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":20,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":21,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":22,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":23,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":24,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":25,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":26,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":27,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":28,"time":1784974101546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} +{"type":"assistant/chunk","seq":29,"time":1784974101576,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}} +{"type":"assistant/chunk","seq":30,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":31,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parallel"}}} +{"type":"assistant/chunk","seq":32,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":33,"time":1784974101666,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":34,"time":1784974101667,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":35,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":36,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":38,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":39,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":41,"time":1784974101726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1784974101727,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"a"}}} +{"type":"assistant/chunk","seq":43,"time":1784974101727,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":44,"time":1784974101756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1784974101757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":46,"time":1784974101821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":47,"time":1784974101822,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":48,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":49,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":51,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":52,"time":1784974101850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1784974101881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":54,"time":1784974101881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1784974101882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"b"}}} +{"type":"assistant/chunk","seq":56,"time":1784974101882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":57,"time":1784974101908,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1784974101909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":59,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."}}}} +{"type":"assistant/chunk","seq":60,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}} +{"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}} +{"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}} +{"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}} +{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[66],"surfaceOp":"append"} +{"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":72,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":73,"time":1784974102505,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}} +{"type":"assistant/chunk","seq":74,"time":1784974102534,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":75,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} +{"type":"assistant/chunk","seq":76,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":77,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":78,"time":1784974102565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":79,"time":1784974102595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":80,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":81,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":82,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"alpha"}}} +{"type":"assistant/chunk","seq":83,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":85,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}} +{"type":"assistant/chunk","seq":86,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":87,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":88,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":89,"time":1784974102626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"beta"}}} +{"type":"assistant/chunk","seq":90,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":91,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":92,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":93,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":94,"time":1784974102689,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":95,"time":1784974102690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":96,"time":1784974102690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":97,"time":1784974102716,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":98,"time":1784974102717,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":99,"time":1784974102748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":100,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":101,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":102,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":103,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":104,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."}}}} +{"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 352b54de66..c919fccec1 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -1,5 +1,5 @@ - banner: - - navigation "会话层级": + - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] - text: · 1 turns - tablist: @@ -7,22 +7,22 @@ - tab "Trajectory" - tab "Waterfall" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop." -- button "Think The user wants me to read two files (a.txt and b.txt) and then reply with \"DONE\". Let me read both files simultaneously.": +- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - - text: Think The user wants me to read two files (a.txt and b.txt) and then reply with "DONE". Let me read both files simultaneously. + - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. - button: - img - text: Read a.txt - button: - img - text: Read b.txt -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll reply with \"DONE\" as instructed.": +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": - img - - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll reply with "DONE" as instructed. + - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. - paragraph: DONE -- text: cache hit 98% · 15,967 tokens · 1 turns · 2 steps -- textbox "输入消息,Enter 发送,Shift+Enter 换行" -- button "添加": +- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- textbox "Message the agent" +- button "Add attachment": - img - combobox "Plan mode": - option "Plan" [selected] @@ -33,4 +33,4 @@ - combobox "Model": - option "DeepSeek-V4-Pro High" [selected] - option "DeepSeek-V4-Pro" -- button "发送" [disabled] +- button "Send message" [disabled] diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts new file mode 100644 index 0000000000..78ac843a64 --- /dev/null +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -0,0 +1,323 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Boot the complete built client graph against one keyless fixture branch. */ +function boot(search: string): void { + history.replaceState(null, '', `/${search}`) + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** Recreate the built client graph while preserving browser-persistent state. */ +function refresh(search: string): void { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + boot(search) +} + +/** Collapse decorative whitespace while preserving the text a user sees. */ +function visibleText(element: Element): string { + return (element.textContent ?? '').replace(/\s+/g, ' ').trim() +} + +/** Identify the interactive Workspace chip by its menu contract. */ +function workspaceChip(): HTMLElement { + const chip = screen.getAllByRole('button', { name: 'Choose workspace' }) + .find(element => element.getAttribute('aria-haspopup') === 'menu') + if (chip === undefined) throw new Error('Workspace chip missing') + return chip +} + +/** Wait for the runtime-owned controlled input to echo a browser edit. */ +async function setComposerText(composer: HTMLElement, value: string): Promise { + fireEvent.change(composer, { target: { value } }) + await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) }) +} + +it('starts a writable page-local draft without inventing a sidebar Workspace', async () => { + boot('?fixture=empty') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await setComposerText(composer, 'keep this local') + + expect({ + headline: visibleText(screen.getByText("Let's start building")), + workspaceDraft: visibleText(workspaceChip()), + sidebar: visibleText(tree), + composerDisabled: (composer as HTMLTextAreaElement).disabled, + prompt: (composer as HTMLTextAreaElement).value, + }).toMatchInlineSnapshot(` + { + "composerDisabled": false, + "headline": "Let's start building", + "prompt": "keep this local", + "sidebar": "No sessions yet", + "workspaceDraft": "workspace", + } + `) +}) + +it('creates a real empty Workspace immediately and focuses its Session draft', async () => { + boot('?fixture=empty') + + await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const workspaceSection = screen.getByText('Workspaces').parentElement + if (workspaceSection === null) throw new Error('Workspace section missing') + fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' })) + + const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' }) + fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), { + target: { value: 'nova' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' })) + + const tree = await screen.findByRole('tree', { name: 'Sessions' }) + await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }) + const group = within(tree).getByText('1 session').closest('[role="treeitem"]') + const draft = within(tree).getByText('New session').closest('[role="treeitem"]') + if (group === null || draft === null) throw new Error('created Workspace projection missing') + + expect({ + workspace: visibleText(group), + draft: visibleText(draft), + draftSelected: draft.getAttribute('aria-selected'), + composerWorkspace: visibleText(workspaceChip()), + }).toMatchInlineSnapshot(` + { + "composerWorkspace": "nova", + "draft": "New session", + "draftSelected": "true", + "workspace": "nova1 session", + } + `) +}) + +it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => { + boot('?fixture') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await setComposerText(composer, 'discard this page-local draft') + const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]') + if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh') + + const before = { + workspace: visibleText(beforeGroup), + draft: visibleText(within(tree).getByText('New session')), + prompt: (composer as HTMLTextAreaElement).value, + } + + refresh('?fixture') + + const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const refreshedTree = screen.getByRole('tree', { name: 'Sessions' }) + const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]') + if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh') + + expect({ + before, + after: { + workspace: visibleText(afterGroup), + replacementDraft: visibleText(within(refreshedTree).getByText('New session')), + prompt: (refreshedComposer as HTMLTextAreaElement).value, + }, + }).toMatchInlineSnapshot(` + { + "after": { + "prompt": "", + "replacementDraft": "New session", + "workspace": "fixture4 sessions", + }, + "before": { + "draft": "New session", + "prompt": "discard this page-local draft", + "workspace": "fixture4 sessions", + }, + } + `) +}) + +it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => { + boot('?fixture&fixtureAttach=fail') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + await setComposerText(composer, 'keep this cwd-only session') + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 }) + const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]') + const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]') + const ungroupedSection = ungroupedGroup?.parentElement + if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) { + throw new Error('Workspace or Ungrouped projection missing') + } + const session = within(ungroupedSection).getByRole('treeitem', { selected: true }) + const retained = screen.getByDisplayValue('keep this cwd-only session') + + expect({ + workspace: visibleText(workspaceGroup), + ungrouped: visibleText(ungroupedGroup), + session: within(session).getByText('fixture', { exact: true }).textContent, + sessionSelected: session.getAttribute('aria-selected'), + prompt: (retained as HTMLTextAreaElement).value, + }).toMatchInlineSnapshot(` + { + "prompt": "keep this cwd-only session", + "session": "fixture", + "sessionSelected": "true", + "ungrouped": "Ungrouped1 session", + "workspace": "fixture3 sessions", + } + `) +}) + +it('materializes the automatic Workspace and Session on the first successful send', async () => { + boot('?fixture=empty') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + await setComposerText(composer, 'build a lighthouse') + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 }) + await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 }) + const group = within(tree).getByText('1 session').closest('[role="treeitem"]') + const session = within(tree).getByRole('treeitem', { selected: true }) + if (group === null) throw new Error('materialized Workspace projection missing') + + expect({ + workspace: visibleText(group), + session: within(session).getByText('workspace', { exact: true }).textContent, + sessionSelected: session.getAttribute('aria-selected'), + promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent, + }).toMatchInlineSnapshot(` + { + "promptVisible": "build a lighthouse", + "session": "workspace", + "sessionSelected": "true", + "workspace": "workspace1 session", + } + `) +}) + +it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => { + boot('?fixture=empty&fixturePrompt=reject') + + const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + await setComposerText(composer, 'do not lose this') + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + + const alert = await screen.findByRole('alert', {}, { timeout: 10_000 }) + const retained = screen.getByDisplayValue('do not lose this') + const tree = screen.getByRole('tree', { name: 'Sessions' }) + await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }) + const group = within(tree).getByText('1 session').closest('[role="treeitem"]') + const session = within(tree).getByRole('treeitem', { selected: true }) + if (group === null) throw new Error('rejected-send Workspace projection missing') + + expect({ + workspace: visibleText(group), + session: within(session).getByText('workspace', { exact: true }).textContent, + error: visibleText(alert), + prompt: (retained as HTMLTextAreaElement).value, + }).toMatchInlineSnapshot(` + { + "error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance", + "prompt": "do not lose this", + "session": "workspace", + "workspace": "workspace1 session", + } + `) +}) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6363add08e..e501a5b277 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,8 +40,10 @@ flowchart LR pkg_storage_json["storage-json"] pkg_storage_sqlite["storage-sqlite"] pkg_storage_domain["storage-domain"] + svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] svc_workspace["ctx.workspace
Workspace entity registry"] + pkg_apiproxy["apiproxy"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] @@ -180,6 +182,7 @@ flowchart LR pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore pkg_storage --> svc_storage + pkg_storage_domain --> svc_storageDomain pkg_storage_json --> svc_storage pkg_storage_sqlite --> svc_storage pkg_subagent --> svc_subagents @@ -253,7 +256,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain - svc_storage --> pkg_workspace + svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop @@ -282,6 +285,7 @@ flowchart LR svc_web --> pkg_tool_web svc_workflows --> pkg_tool_ralph svc_workflows --> pkg_tool_workflow + svc_workspace --> pkg_apiproxy svc_fs -. event gate .-> pkg_fs_policy ``` @@ -293,8 +297,9 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | -| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | - | - | Owns WorkspaceId-branded records over the domain form; sessionIds is the single source of ownership truth. RPC and GUI consumers arrive next phase. | +| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6861a77558..d437649d9b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -487,19 +487,21 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agents` · `sessions` · `tools` · `userInteraction` +Requires: `agents` · `sessions` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config: the host-level default agent routing. */ +/** Gateway plugin config: host-level agent routing and Workspace creation root. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string + /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ + workspaceRoot?: string } ``` -Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` @@ -755,12 +757,12 @@ Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src Requires: `tools` ```ts config-catalog -/** Discriminated union of all supported MCP transport configurations. */ +/** Configuration for one stdio or Streamable HTTP MCP server. */ export type Config = StdioConfig | StreamableHttpConfig /** Config for connecting to an MCP server via a spawned child process over stdio. */ export interface StdioConfig { - /** Transport type: spawn a child process and communicate over stdio. */ + /** Selects child-process stdio transport. */ transport: 'stdio' /** * Stable local namespace for this server's model-facing tool names @@ -768,21 +770,21 @@ export interface StdioConfig { * unique across live mcp-client instances. */ serverName: string - /** Executable to spawn. */ + /** Executable used to start the server. */ command: string - /** Arguments passed to the command. */ + /** Arguments passed directly, without shell interpolation. */ args: string[] /** Extra env vars merged on top of scrubbed ambient env. */ env: Record /** Working directory for the child process. */ cwd: string - /** Timeout per callTool invocation (ms). */ + /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ export interface StreamableHttpConfig { - /** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ + /** Selects Streamable HTTP transport. */ transport: 'streamable-http' /** * Stable local namespace for this server's model-facing tool names @@ -790,11 +792,11 @@ export interface StreamableHttpConfig { * unique across live mcp-client instances. */ serverName: string - /** MCP server URL. */ + /** MCP endpoint URL. */ url: string - /** Extra headers (e.g. auth tokens). */ + /** Additional headers attached to MCP requests. */ headers: Record - /** Timeout per callTool invocation (ms). */ + /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number } ``` @@ -980,9 +982,9 @@ export interface Config { /** * Root directory for all session files. Required (no default): a default of * `process.cwd()` would scatter session files as the process's cwd changes - * (bash calls, subprocesses). Sessions group under per-cwd subdirectories. An - * existing root must be a readable directory; an absent root is created on - * first materialization. + * (bash calls, subprocesses). Sessions group under human-readable project + * directories, then per-session directories. An existing root must be a + * readable directory; an absent root is created on first materialization. */ root: string /** @@ -1219,7 +1221,7 @@ export interface Config { } ``` -Source: [`packages/storage/storage-domain/src/index.ts:45`](../packages/storage/storage-domain/src/index.ts) +Source: [`packages/storage/storage-domain/src/index.ts:52`](../packages/storage/storage-domain/src/index.ts) ## `@deepseek-ai/dsh-storage-json` @@ -2026,6 +2028,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) @@ -2042,7 +2045,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) -- `@deepseek-ai/dsh-workspace` — requires `storage` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) +- `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) ## Seam packages (not directly loadable) @@ -2073,7 +2076,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) -- `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 86fc08803c..2e30e8602b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1443,7 +1443,50 @@ mount(form: K, facility: StorageForms[K]): () => v form(form: K): StorageForms[K] ``` -Source: [`packages/storage/storage/src/index.ts:35`](../../packages/storage/storage/src/index.ts) +Source: [`packages/storage/storage/src/index.ts:47`](../../packages/storage/storage/src/index.ts) + +## `ctx.storageDomain` — `DomainFacility` + +The mounted domain facility. Opens declared domains over routed backends; one facility instance owns the open-domain table and enforces single-open per domain name. + +```ts cordis-catalog +/** + * Open one declared domain. Steps, each failing the whole call: reject a + * name that is already open (`already-open`); resolve the backend route + * (`backend-not-found` passes through from the hub); require its `kv` facet + * (`facet-unsupported`); open the unit projected from the spec (backend + * `version-mismatch`/`malformed-medium` pass through); load and validate + * every stored record against the spec's zod schemas (`invalid-record` + * with the offending table and key); construct the domain. + * + * Lifecycle: the CALLER owns the returned handle and closes it via + * `Domain.close()` (typically as its own `ctx.effect` disposer) — the + * facility does not tie the domain to any consumer fiber. Domains still + * open when the facility unmounts are closed by the plugin disposer. + * @param spec - The domain declaration, typically from `defineDomain`. + * @returns the opened domain handle, typed by the spec. + */ +async open(spec: S): Promise> + +/** + * Look up an open domain by name, untyped. Diagnostic surface (the package + * invariant cross-checks change events against live domain state); typed + * consumers hold the handle returned by {@link open}. + * @param name - Domain name. + * @returns the open domain runtime, or `undefined` when not open. + */ +get(name: string): DomainImpl | undefined + +/** + * Close every domain still open on this facility. The unmount path for + * consumers that never called `Domain.close()` themselves; closing is + * idempotent, so double-closing an already-closed domain is harmless. + * @returns resolution after every unit is released. + */ +async closeAll(): Promise +``` + +Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/storage/storage-domain/src/index.ts) ## `ctx.subagents` — `SubagentService` @@ -1907,49 +1950,59 @@ Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/ ## `ctx.workspace` — `WorkspaceRegistry` -The workspace registry service. Opens the `workspace` domain at startup, rebuilds one entity per stored record, and serves entities from an in-memory cache keyed by id. Session persistence is an OPTIONAL peer (resolved via `ctx.get`, never injected): while it is absent, session attachment rejects (what cannot be validated is not recorded) and `sessionIds` projections serve the account unfiltered. - -There is deliberately no delete entry point in this phase: workspace deletion ships as one complete semantic together with the session-cascade primitives (future work in the owning Agent Note). +Durable workspace registry. Startup waits for `sessionPersistence`, builds one canonical-cwd header index, and completes the one-time history bootstrap before the service becomes active. The persistence dependency is mandatory so an unavailable peer can never be mistaken for an empty history and commit the initialized marker. ```ts cordis-catalog /** - * Create a workspace over an existing directory. The path is canonicalized - * through `fs.realpath` first — a nonexistent path rejects with the - * original `ENOENT`, a path resolving to anything but a directory rejects, - * and a canonical path already owned by another workspace (including a - * symlink resolving to it) rejects. - * @param path - Directory the workspace points at; canonicalized before storing. - * @param title - Display title; defaults to `basename` of the canonical path. - * @returns the created workspace after durability. + * Create or reuse a workspace for an existing directory. The path is + * canonicalized through `fs.realpath`; a nonexistent path rejects with the + * original error and a non-directory rejects. Repeated calls for the same + * canonical path return the existing entity without changing its title. + * A newly created workspace is prepended to the durable registry order. + * A different canonical path cannot create a duplicate display title. + * @param path - Existing directory to own, in any path spelling. + * @param title - Display title used only when a new record is created. + * @returns the existing or newly durable workspace. */ async create(path: string, title?: string): Promise /** * Look up a workspace by id. - * @param id - The workspace id. + * @param id - Workspace id. * @returns the workspace, or `undefined` when unknown. */ get(id: WorkspaceId): Workspace | undefined /** - * Snapshot of all workspaces, in load-then-creation order. - * @returns a fresh array of the cached entities. + * Synchronous workspace projection in durable registry order. Every + * entity's `sessionIds` getter is already filtered by the startup/live + * canonical-cwd header index; this method performs no persistence reads. + * @returns a fresh ordered array of workspace entities. */ list(): Workspace[] /** - * Resolve a workspace by directory path, through the same `fs.realpath` - * canon as {@link create} (hence async). A path that does not exist rejects - * with the original error — a missing directory has no canonical form to - * compare (a workspace whose recorded directory vanished is only reachable - * by id; see `Workspace.status`). - * @param path - Directory path in any spelling (symlinks, `..`, trailing slash). - * @returns the owning workspace, or `undefined` when none matches. + * Move one accounted, cwd-validated session to the front of its workspace. + * Ungrouped sessions and candidates filtered by the header check are + * no-ops. The owning workspace's relative position never changes. + * @param sessionId - Session whose activity was observed. + * @returns resolution after the possible record write. + */ +async touchSession(sessionId: SessionId): Promise + +/** + * Resolve by canonical directory path without creating or mutating a + * workspace. A missing path rejects during `realpath`; an existing unowned + * directory returns `undefined`. + * @param path - Existing directory path in any spelling. + * @returns the workspace owning the canonical path, when one exists. */ async resolveByPath(path: string): Promise ``` -Source: [`packages/workspace/workspace/src/index.ts:60`](../../packages/workspace/workspace/src/index.ts) +Types: [SessionId](../core-data-structures/core.md) + +Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 28191bd56c..5de51d2794 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.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 -llm-streaming.md: cb99c935aea2dc9cc769e3056fdb98a2e5c9eacb -llm-streaming.zh.md: 740fa1f796088e63d0195cfbecf975adb236381c +llm-streaming.md: fb97e74a9ec01e62ba940295112fb157cc73bd1d +llm-streaming.zh.md: fda59a64aeef1c037372d69dbc15ee0de48de222 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index cb99c935ae..fb97e74a9e 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -63,6 +63,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt. - **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. +- **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md). - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 740fa1f796..fda59a64ae 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -63,6 +63,7 @@ interface LlmFailure { - **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 +- **空 completion 是可重试错误,而不是静默的成功结果。** 两个适配器都把没有携带任何内容块的终止性 `stop` 结束映射为携带规范 `EMPTY_RESPONSE` code 的 `finish {kind:'error'}`,`dsh-llm-retry` 默认会重试它;详见[空模型响应可重试](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md)。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 - **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index b7d8fda4e8..4ea02cbc2c 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.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 -persistence.md: dc497fd85f44660c0a981579351b5cfbe0040a4d -persistence.zh.md: 5236f4fe2ba8ad1be7e74bffafebfea19014d7aa +persistence.md: b03cc07d2e514b3900d4035ea386f31c761470a7 +persistence.zh.md: 3030ff2fe949cb02385331800d826df227e3d6cd diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index dc497fd85f..b03cc07d2e 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -20,7 +20,7 @@ Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id ## `SessionLocation` — optional per-session artifact target -`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. +`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns the absolute transcript path inside its project/session directory; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. ```ts type-equiv /** diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 5236f4fe2b..3030ff2fe9 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -20,7 +20,7 @@ ## `SessionLocation`——可选的逐会话产物目标 -`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立产物,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。 +`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立产物,而不会读取、创建或 flush 它。JSONL 返回其项目/会话目录内 transcript(文本记录)的绝对路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。 ```ts type-equiv /** diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d8d6a493d7..af4b1decda 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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 -subagent.md: 0335a3f0780ae17b57ae730f5a49a269261c8073 -subagent.zh.md: dac48b624f6e0cfc28737e3e1a2774ba2d97e85b +subagent.md: 2497dbab9cfc8304eb7aaeba7109404ac614bbff +subagent.zh.md: 2d96e9bc635951746e72ed58a7c3638dc2598cc2 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 0335a3f078..2497dbab9c 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -19,16 +19,13 @@ A provider advertises its **start-time** features on a static descriptor the ser * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * degradation" rule). These static flags cover features needed before a run exists; runtime * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence - * is the capability. + * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option: + * `depthLimit` to `maxDepth`; the other names match. */ interface SubagentCapabilities { - /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ readonly outputSchema: boolean - /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */ readonly depthLimit: boolean - /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ readonly toolFilter: boolean - /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */ readonly persona: boolean } ``` @@ -45,16 +42,12 @@ The tool layer builds this request from the model input and its own config; the * passes it to {@link SubagentProvider.start}. */ interface SubagentStartRequest { - /** The task/prompt for the child agent (a user message in the child session). */ + /** Content delivered as the child's user message. */ readonly prompt: ContentBlock[] /** - * The spawning ("parent") agent — the one whose tool call started this - * subagent. REQUIRED: in-process backends read `parent.session.header` for - * the working directory, the `parentSession` lineage to stamp on the child, - * and the parent's delegation depth. The out-of-process backend (ACP) reads - * exactly one field — the session header's cwd, the child's workspace when - * no deployment `cwd` override is configured; nothing else crosses the - * process boundary. + * The spawning agent. In-process providers derive workspace, lineage, and + * delegation depth from its durable session state. ACP reads only its cwd, + * and only when no deployment `cwd` override is configured. */ readonly parent: Agent /** @@ -65,7 +58,6 @@ interface SubagentStartRequest { * afterward. */ readonly signal: AbortSignal - /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects @@ -137,9 +129,9 @@ interface SubagentResult { interface SubagentStopReasonMap { /** The child finished its turn normally. */ completed: 'completed' - /** The run was cancelled by its request signal or by disposal. */ + /** Cancelled through the request signal or disposal. */ aborted: 'aborted' - /** The child failed (model error, transport error). */ + /** Model or transport failure. */ error: 'error' /** The child hit its token ceiling before finishing. */ 'max-tokens': 'max-tokens' @@ -180,9 +172,8 @@ interface SubagentRun { */ readonly result: Promise /** - * Cancel remaining work, reach child quiescence, and release the run's - * resources (in-process: dispose the owned agent and remove its session; - * ACP: kill and reap the subprocess). Idempotent. + * Cancel remaining work, reach child quiescence, and release resources. + * Idempotent. */ dispose(): Promise /** @@ -206,12 +197,9 @@ Each provider is a named child-agent transport, and multiple providers may coexi ```ts type-equiv /** - * A subagent backend: one transport for running a child agent (in-process - * spawn/fork, ACP to another process, …). Implementations register under a - * unique name via {@link SubagentService.registerProvider}; multiple providers - * coexist in one context (unlike the single-implementation bash seam). The - * Providers are trusted same-process implementations; callers treat their - * descriptors and returned values as borrowed immutable data. + * One registered transport for running child agents. Providers are trusted + * same-process implementations; callers treat descriptors and returned values + * as borrowed immutable data. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index dac48b624f..2d96e9bc63 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -19,16 +19,13 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [ba * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * degradation" rule). These static flags cover features needed before a run exists; runtime * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence - * is the capability. + * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option: + * `depthLimit` to `maxDepth`; the other names match. */ interface SubagentCapabilities { - /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ readonly outputSchema: boolean - /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */ readonly depthLimit: boolean - /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ readonly toolFilter: boolean - /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */ readonly persona: boolean } ``` @@ -45,16 +42,12 @@ interface SubagentCapabilities { * passes it to {@link SubagentProvider.start}. */ interface SubagentStartRequest { - /** The task/prompt for the child agent (a user message in the child session). */ + /** Content delivered as the child's user message. */ readonly prompt: ContentBlock[] /** - * The spawning ("parent") agent — the one whose tool call started this - * subagent. REQUIRED: in-process backends read `parent.session.header` for - * the working directory, the `parentSession` lineage to stamp on the child, - * and the parent's delegation depth. The out-of-process backend (ACP) reads - * exactly one field — the session header's cwd, the child's workspace when - * no deployment `cwd` override is configured; nothing else crosses the - * process boundary. + * The spawning agent. In-process providers derive workspace, lineage, and + * delegation depth from its durable session state. ACP reads only its cwd, + * and only when no deployment `cwd` override is configured. */ readonly parent: Agent /** @@ -65,7 +58,6 @@ interface SubagentStartRequest { * afterward. */ readonly signal: AbortSignal - /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects @@ -137,9 +129,9 @@ interface SubagentResult { interface SubagentStopReasonMap { /** The child finished its turn normally. */ completed: 'completed' - /** The run was cancelled by its request signal or by disposal. */ + /** Cancelled through the request signal or disposal. */ aborted: 'aborted' - /** The child failed (model error, transport error). */ + /** Model or transport failure. */ error: 'error' /** The child hit its token ceiling before finishing. */ 'max-tokens': 'max-tokens' @@ -182,9 +174,8 @@ interface SubagentRun { */ readonly result: Promise /** - * Cancel remaining work, reach child quiescence, and release the run's - * resources (in-process: dispose the owned agent and remove its session; - * ACP: kill and reap the subprocess). Idempotent. + * Cancel remaining work, reach child quiescence, and release resources. + * Idempotent. */ dispose(): Promise /** @@ -208,12 +199,9 @@ interface SubagentRun { ```ts type-equiv /** - * A subagent backend: one transport for running a child agent (in-process - * spawn/fork, ACP to another process, …). Implementations register under a - * unique name via {@link SubagentService.registerProvider}; multiple providers - * coexist in one context (unlike the single-implementation bash seam). The - * Providers are trusted same-process implementations; callers treat their - * descriptors and returned values as borrowed immutable data. + * One registered transport for running child agents. Providers are trusted + * same-process implementations; callers treat descriptors and returned values + * as borrowed immutable data. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bba7989556..d9521f7d67 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,7 +28,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | +| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace`](../packages/workspace/workspace), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index d5845bf041..9e8b3adf8a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -147,6 +147,7 @@ flowchart TD pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_trajectory["client-ui-trajectory"] + pkg_client_ui_workspace["client-ui-workspace"] pkg_client_web["client-web"] pkg_client_web_react["client-web-react"] end @@ -171,7 +172,6 @@ flowchart TD end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] - pkg_host_runtime["host-runtime"] pkg_host_webserver["host-webserver"] end subgraph group_lsp["packages/lsp"] @@ -238,7 +238,6 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants - pkg_host_runtime --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_llm --> pkg_brand @@ -259,6 +258,10 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_telemetry --> pkg_brand @@ -808,7 +811,6 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | -| [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -817,6 +819,7 @@ flowchart TD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | diff --git a/examples/acp-agent/retry.cordis.snapshot.yml b/examples/acp-agent/retry.cordis.snapshot.yml new file mode 100644 index 0000000000..4d7010f774 --- /dev/null +++ b/examples/acp-agent/retry.cordis.snapshot.yml @@ -0,0 +1,41 @@ +# Keyless replay for the retry overlay: disable the key-requiring DeepSeek +# adapter, insert `llm-replay`, and restate the app config with the same +# deterministic 1 ms zero-jitter retry policy as the live sibling. A config +# patch replaces the whole app config, so the base fields are restated +# verbatim (raw JSONL persistence so the harness can harvest the log). +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + llmRetry: + maxTransientRetries: 2 + initialDelayMs: 1 + maxDelayMs: 1 + jitterRatio: 0 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml new file mode 100644 index 0000000000..bbb3f81c0d --- /dev/null +++ b/examples/acp-agent/retry.cordis.yml @@ -0,0 +1,30 @@ +# Retry-scenario overlay: pin the bounded transient retry policy to a +# deterministic 1 ms zero-jitter delay so the durable `llm/retry` event +# (`delayMs`) and replay wall time stay reproducible. The overlay changes no +# tool or prompt composition, so its scenarios share the default header class. +# A config patch replaces the whole app config, so the base fields are restated +# verbatim; the model is re-pinned to `deepseek-v4-flash` like the other +# snapshot overlays because the recorded corpus was captured on flash. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + llmRetry: + maxTransientRetries: 2 + initialDelayMs: 1 + maxDelayMs: 1 + jitterRatio: 0 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index b51498e90c..16bf2fbf72 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -114,7 +114,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify the WORLD, not the agent's self-report: read the file from disk. + // Assert the filesystem effect independently of the model response. const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') expect(proof).toContain('ACP_OK') diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 80acd39d6e..60eaa6285b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -38,6 +38,7 @@ const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) +const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -112,6 +113,14 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-policy-reject', hasModelTurn: true, recorded: true }, { name: 'multi-turn', hasModelTurn: true, recorded: true }, { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true }, + // Keyless, authored (like error-finish): a live provider cannot be coaxed + // into a degenerate empty completion, so the fixture scripts the adapters' + // EMPTY_RESPONSE error finish (step 1) followed by the recovered reply + // (step 2), proving the default retry policy end to end: the durable + // llm/retry event, no ACP output for the discarded attempt, the recovered + // reply, and a clean completed turn. Its overlay only pins a deterministic + // 1 ms zero-jitter delay, so it shares the default header class. + { name: 'empty-response-retry', hasModelTurn: true, recorded: false, configPath: RETRY_CONFIG }, // Keyless, authored (like error-finish/cancel): deterministically forcing a // LIVE model to repeat one call three times is not a stable recording, so // the fixture scripts five identical todo_write calls and pins BOTH reminder diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index 49669e6b9b..b99d022c17 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -62,7 +62,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook // the model, not a turn failure). expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify that the denied hook left no filesystem effect. + // Assert the denied operation independently of the model response. await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow() // ACP publishes only the committed answer; hook/tool trace stays in the session log. diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/input.json b/examples/acp-agent/tests/snapshots/empty-response-retry/input.json new file mode 100644 index 0000000000..edc8fdb19f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "This prompt first receives an empty completion, then a retried reply." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl new file mode 100644 index 0000000000..f164c7fe62 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -0,0 +1,19 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt first receives an empty completion, then a retried reply."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"This prompt first receives an","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}} +{"type":"step/end","seq":7,"time":0,"data":{"turn":1,"step":1}} +{"type":"llm/retry","seq":8,"time":0,"data":{"turn":1,"step":1,"retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} +{"type":"step/start","seq":9,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"Recovered."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl new file mode 100644 index 0000000000..1ca475b573 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Recovered."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/knip.json b/knip.json index b6a1fb63d7..2d437dd421 100644 --- a/knip.json +++ b/knip.json @@ -57,11 +57,6 @@ ] }, "packages/host/webserver": { - "project": [ - "src/**/*.ts" - ] - }, - "packages/host/runtime": { "entry": [ "tests/**/*.spec.ts" ], diff --git a/missions/readme.md b/missions/readme.md new file mode 100644 index 0000000000..66167719ad --- /dev/null +++ b/missions/readme.md @@ -0,0 +1,36 @@ +# Workspace GUI 收尾备忘 + +## 产品改动 + +- 用户要求“去掉功能”时,先拆开视觉入口、可访问性语义和响应行为分别确认。本次 composer 加号保留原样和 `Add attachment` 标签,只在组合层停止传入 Workspace 回调;不要删除按钮、改样式或把它禁用。 +- 临时交互不应上浮到 React 呈现层。Session/Workspace Intent、首次消息保留和 materialize 重试归 runtime 对象与 service;组件只接收标准 action、hooks 和纯呈现状态。 +- RFC、测试名称和 PR 描述只写最终产品语义,不保留 `reconcilePublishedDraft`、`pendingCwd` 等已经撤销的中间方案。 + +## Snapshot 与测试定位 + +- `apps/web/tests/**/*.snapshot.ts` 验证 built application,需用 `DSH_EXAMPLE_MODE=lib`,并确认相关 `lib/` 已由当前源码构建;普通 source-mode Vitest 通过不能替代它。 +- 对 runtime 管理的受控输入执行 `fireEvent.change` 后,必须 `waitFor` 输入值回显再点击发送,否则发送可能读取旧的空 prompt。 +- 页面中 Workspace 与 Session 可以同名,禁止用无作用域的 `findByText` 定位。先用 `within` 锁定 Sessions tree、计数或对应 group,再找目标行。 +- 新 push 后先看 assembled snapshot 是否真正跑过;本地 focused snapshot 通过后仍以 `gh pr checks` 的 artifact job 为准。 + +## Coverage 收口 + +- 测试筛选和 coverage 筛选是两件事。用 owning tests 配合逐个 `--coverage.include=''`,先拿到真实未覆盖行和分支,不要直接反复跑全仓 coverage。 +- 多个 coverage 进程并发时必须给不同的 `--coverage.reportsDirectory`,否则报告目录互相覆盖。各 worker 完成后再跑一次合并后的精确 coverage,确认共享 worktree 的改动组合起来仍为 100%。 +- 全仓 coverage 若先被无关测试超时打断,不能把它当作目标文件的结论;先用精确 include 修本分支缺口,再让 CI exhaustive coverage 验证整体。 +- Coverage 测试仍要描述行为,不写“为了覆盖某分支”的注释。不可达分支才使用已有规范允许的 `v8 ignore`,可达分支补真实行为测试。 + +## 并发与提交 + +- Coverage 适合按不相交写区并发:例如 Sidebar tests、Workspace picker tests、connection/storage tests。派工时明确“只改 tests、不改 src、不 commit、不得回滚他人改动”。 +- 不直接信任各 worker 的单独结果;主会话审查 diff、运行合并后的 focused coverage、清理生成报告,再统一 commit。 +- 推送前按 `dsh-pre-push-checks` 选择最小充分验证,不重复已经通过的检查;正常 push 让 pre-push typecheck 运行,并核对本地 HEAD 与远端 ref 一致。 +- 生成的 `.coverage/` 只属于本地诊断。环境拒绝 `rm -rf` 时,依次使用 `find .coverage -type f -delete` 和 `find .coverage -depth -type d -empty -delete`;不要让报告进入 commit。 + +## GitHub 与 CI + +- GitHub 操作统一走 `gh`,并从 git 配置注入代理:`proxy="$(git config --get http.https://github.com.proxy)"; https_proxy="$proxy" http_proxy="$proxy" GH_PAGER=cat ~/.local/bin/gh ...`。不要改用网页。 +- 每次 push 都会产生一轮新 checks;旧轮次的失败不能代表当前 HEAD。先确认 run 对应当前提交,再拉失败日志。 +- `gh run watch` 只监视一个 workflow。最终必须用 `gh pr checks` 汇总 CI、e2e、sandbox 和 Windows 等独立 workflow;偶发平台失败先等当前 HEAD 重跑结果,不预先修改无关代码。 +- PR base 和 description 在最终 push 后再次用 `gh pr edit --base ... --body-file ...` 同步。PR 描述应包含最终产品动线、架构边界和实际运行过的验证,不写仍待执行的承诺。 +- Review thread 用 GraphQL/`gh api` 检查 `isResolved` 和已有回复,避免对已经解决的旧实现评论重复修复。 diff --git a/missions/tasks/20260724-storage-workspace/dev-plan.md b/missions/tasks/20260724-storage-workspace/dev-plan.md deleted file mode 100644 index da621897ba..0000000000 --- a/missions/tasks/20260724-storage-workspace/dev-plan.md +++ /dev/null @@ -1,179 +0,0 @@ -# Storage + Workspace 工程开发文档 - -> 施工范围:5 个新包,session 侧零 diff。规范正典:[Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)——本文只写工程拆解(目录/文件、class 落位、teammate 分工、并行依赖),接口语义以 Note 为准,冲突时改这里不改 Note(除非经用户拍板)。 -> 门禁口径:GUI 免门禁期同款——不随手写测试门禁,跑 typecheck/build 保证编译;测试文件按仓库惯例落位(包级 `tests/`、`.spec.ts`),红绿在 PR 窗口收口。 - -## 0. 总览 - -``` -packages/storage/ - storage/ dsh-storage 枢纽:Storage service + BackendRegistry + StorageForms - storage-json/ dsh-storage-json JsonStorageBackend(kv facet) - storage-sqlite/ dsh-storage-sqlite SqliteStorageBackend(kv facet) - storage-domain/ dsh-storage-domain DomainFacility + Domain + KvTable + domain/changed -packages/workspace/ - workspace/ dsh-workspace WorkspaceRegistry + WorkspaceEntity + workspaceDomainSpec -``` - -依赖与并行关系(→ = 依赖): - -``` -W1 storage(枢纽) ──→ W2a storage-json ──┐ - └──→ W2b storage-sqlite ─┼──→ 集成冒烟(W4 兼) - └──→ W3 domain ──────────┘ - └──→ W4 workspace -``` - -- W1 先行(接口包是所有人的编译依赖),完成后 W2a/W2b/W3 **三线并行**;W4 依赖 W3 的接口定型(不必等 json/sqlite 完工,可对着 W3 的类型先写,用内存假 backend 跑测试)。 -- 每包的 package.json/tsconfig/README/invariant 伴生由该包 owner 自己配齐(模板照抄 `packages/session-persistence/session-persistence-sqlite/` 的形状)。 - -## 1. W1:`dsh-storage`(枢纽)——主线程自做 - -量小且是全组编译根,主线程直接写,不派 teammate。 - -``` -packages/storage/storage/ - package.json # 无运行时依赖;cordis peerDep + dev - tsconfig.json - src/index.ts # Storage service + apply + 全部导出 - src/registry.ts # BackendRegistry - src/backend.ts # StorageBackend/KvFacet/KvUnitDescriptor/KvUnit 类型 - src/error.ts # StorageError + code 联合 - src/invariant.ts # 见下 - tests/registry.spec.ts # registry/mount 套件 - README.md -``` - -class/接口逐条(签名以 Note 为准,此处列实现要点): - -| 成员 | 实现要点 | -| --- | --- | -| `class Storage extends Service` | `super(ctx, 'storage')`;`readonly backend = new BackendRegistry()`;`mount(form, facility)` 存入私有 `Map`,重复 → `StorageError('duplicate-mount')`,返回删除闭包;`get domain()` 从 map 取,缺 → `StorageError('form-not-mounted')` | -| `class BackendRegistry` | 私有 `Map`;`register` 重名 → `duplicate-backend`,返回 `() => map.delete(name)`;`get` 缺名 → `backend-not-found`;`names()` 返回数组拷贝 | -| `interface StorageForms {}` | 空接口 + JSDoc(merge-extensible,键 = 数据形式名) | -| `interface StorageBackend / KvFacet / KvUnitDescriptor / KvUnit` | 纯类型 + 契约 JSDoc(七条契约写在 KvUnit 各方法 JSDoc 上——这是 backend 实现者的规范文本) | -| `class StorageError extends Error` | `constructor(code, message?, cause?)`;`name = 'StorageError'` | -| `const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/` | 导出;descriptor 校验用(backend open 时验,fail loud) | -| invariant | 枢纽自身无运行时不变量(纯注册表,无事件流/可变盘面),写"explained empty"(措辞照抄 sqlite 后端 invariant.ts 的 "No runtime invariant:" 模板) | - -事件面:本包**无**事件(`domain/changed` 归 dsh-storage-domain)。 - -## 2. W2a:`dsh-storage-json` —— teammate **json-backend** - -``` -packages/storage/storage-json/ - src/index.ts # Config + apply + JsonStorageBackend - src/unit.ts # JsonKvUnit - src/atomic.ts # temp+fsync+rename 原子写(含 win32 分支) - src/format.ts # 文件格式 parse/serialize + malformed 检查 - src/invariant.ts - tests/json-backend.spec.ts # 挂共享契约套件(见 §5)+ json 特有(文件肉眼格式、malformed) -``` - -| class | 要点 | -| --- | --- | -| `Config` | schemastery,`root: z.string().required()`(JSDoc 说明为何无默认:防 cwd 散落,参照 session-persistence 措辞) | -| `class JsonStorageBackend implements StorageBackend` | `name='json'`;`kv = { open }`;持 `Map`(同名重复 open → 复用还是报错:**报错**,unit 生命周期归调用方,double-open 是 bug);`close()` 逐 unit close,幂等 | -| `class JsonKvUnit implements KvUnit` | 内存态 `{ version, global, tables: Map> }` 为权威;构造时读盘:文件缺失 = 空单元(不落盘),存在则 parse + 版本比对;每个写原语 = 改内存 → `writeAtomic(serialize())`;**写不排队**(契约第 4 条:串行是调用方的事),但单次 writeAtomic 内部完整(temp/fsync/rename);close 后操作 → `closed` | -| `atomic.ts` | `writeAtomic(path, data)`:同目录 temp 文件 + fsync + rename;win32 分支照抄 `session-persistence-jsonl/src/win32.ts` 的替换语义(先照抄,`log` facet 迁移期再提共享——Note 已记)| -| `format.ts` | `serialize(unit): string`(`JSON.stringify(…, null, 2)` + 尾换行);`parse(text): ParsedUnit`,缺 `unit` 头/结构不符 → `malformed-medium` | -| apply | `ctx.effect(() => { const d = ctx.storage.backend.register('json', backend); return async () => { d(); await backend.close() } })`;inject: `['storage']` | -| invariant | 断言候选:rename 发布后盘上文件必可 parse 回等价内存态(写后读回校验,仅测试态开启);若判断无运行时可断言关系则 explained empty | - -## 3. W2b:`dsh-storage-sqlite` —— teammate **sqlite-backend** - -``` -packages/storage/storage-sqlite/ - src/index.ts # Config + apply + SqliteStorageBackend - src/unit.ts # SqliteKvUnit - src/schema.ts # SCHEMA_VERSION + openDatabase + DDL - src/invariant.ts - tests/sqlite-backend.spec.ts -``` - -| class | 要点 | -| --- | --- | -| `Config` | `path: z.string().required()`(`:memory:` 允许)+ `journalMode` 枚举 default 'wal' | -| `schema.ts` | `STORAGE_SQLITE_SCHEMA_VERSION = 1`;`openDatabase(config)` 照抄 session-persistence-sqlite 的序列(mkdir 0o700 → wx 0o600 建文件 → PRAGMA foreign_keys → journal_mode → user_version 检查盖章/拒绝 → 建 `units`/`unit_globals`);**先照抄不提共享 helper**(Note 已记:提取放迁移期) | -| `class SqliteStorageBackend` | `name='sqlite'`;单 `DatabaseSync` 连接;`kv.open(descriptor)`:校验名字字符集 → `units` 行版本比对(无行则 INSERT 盖章)→ 按 descriptor.tables 逐张 `CREATE TABLE IF NOT EXISTS "u__"` → 返回 unit;`close()` 关连接 | -| `class SqliteKvUnit` | 预编译语句(每表 upsert/delete/select-all + global upsert);`loadAll` 全表 SELECT 组装;`putRecord` = `INSERT … ON CONFLICT(key) DO UPDATE`;单语句原子,无显式事务;value `JSON.stringify`/parse | -| invariant | 断言候选:STRICT 表 + user_version 与常量一致(open 后检);或 explained empty | - -## 4. W3:`dsh-storage-domain` —— teammate **domain-layer** - -``` -packages/storage/storage-domain/ - src/index.ts # Config + apply + DomainFacility - src/spec.ts # DomainSpec/defineDomain/domainTable + descriptorOf - src/domain.ts # DomainImpl + KvTableImpl + 写链 - src/events.ts # domain/changed declaration merging - src/error.ts # DomainError - src/invariant.ts - tests/domain.spec.ts # 用内存假 backend(tests/helpers/memory-backend.ts) -``` - -| class | 要点 | -| --- | --- | -| `Config` | `backend: z.string().required()` + `routes: z.dict(z.string()).default({})` | -| `spec.ts` | `defineDomain` 恒等函数(编译期收窄)+ 名字/表名正则校验(违规 throw,misconfiguration fails loud);`descriptorOf(spec)` 投影 | -| `class DomainFacility` | 持 `Map`(already-open 检查);`open(spec)` 按 Note 六步实现;zod 依赖在此包(dependencies,不是 peer) | -| `class DomainImpl` | 写链 `chain: Promise`(`enqueue(job): Promise` 私有方法,所有写走它);内存态 `Map>` + global;每写:链上 → 改内存 → unit 原语 await → `ctx.emit('domain/changed', …)`;dispose:`enqueue(noop)` 排空 → `unit.close()` | -| `class KvTableImpl` | 读同步走内存;`update` fn 同步纯(类型上 `(current: V) => V`),缺 key → `missing-key`;`delete` 返回是否存在 | -| `events.ts` | 按 Note 全文(`@mode emit` + `@param`);`DomainChanged` 接口导出 | -| invariant | 断言候选(真不变量,建议做):**每次 `domain/changed` 事件的 value 必等于内存态当前值**(事件流 vs 可变数据的 owned relationship,正合仓库 invariant 规范)| -| tests/helpers/memory-backend.ts | `MemoryStorageBackend`:Map 实现 KvUnit,宣称版本可注入——共享给 W4 用 | - -## 5. 共享 backend 契约套件 —— domain-layer 兼写(或主线程) - -``` -packages/storage/storage/tests/contract.ts # export function runKvBackendContract(factory) -``` - -- 仿 `runPersistenceContract` 形状:`factory: () => Promise<{ backend, reopen(): Promise }>`,两后端 spec 文件各自 import 调用。 -- 覆盖 Note 七条契约 + 版本拒绝 + close 幂等;"崩溃再 open"用 `reopen()`(新实例指向同一介质)模拟。 -- 落在接口包 tests/ 下(不进 src,不发布),json/sqlite 的 devDependencies 指向 workspace 接口包即可复用。 - -## 6. W4:`dsh-workspace` —— teammate **workspace-domain** - -``` -packages/workspace/workspace/ - src/index.ts # apply + WorkspaceRegistry(service 挂 ctx.workspace) - src/types.ts # WorkspaceId brand + Workspace 接口 - src/spec.ts # workspaceRecord zod + workspaceDomainSpec - src/entity.ts # WorkspaceEntity(不出包:index.ts 不 re-export) - src/paths.ts # realpathNormalize(path) - src/invariant.ts - tests/workspace.spec.ts # MemoryStorageBackend + 假 sessionPersistence stub -``` - -(删除入口本期不存在:registry 无 delete、entity 无关联清理——整套删除语义在 Agent Note 的 future work 节。) - -| class | 要点 | -| --- | --- | -| `types.ts` | `WorkspaceId` brand + 工厂;`Workspace` 接口(Note 签名照录,JSDoc 齐全——这是对外契约) | -| `spec.ts` | `workspaceRecord`(path/title/sessionIds/createdAt/updatedAt)+ `workspaceDomainSpec = defineDomain({ name: 'workspace', version: 1, tables: { workspaces: … } })` | -| `paths.ts` | `realpathNormalize(p): Promise`——`fs.realpath`;ENOENT 原样抛(create 的 reject 路径) | -| `class WorkspaceRegistry extends Service` | `super(ctx, 'workspace')`;inject `['storage', 'sessionPersistence']`(sessionPersistence optional:`ctx.get()` 取,缺席时 attach 拒绝);`start()`:`ctx.storage.domain.open(workspaceDomainSpec)` + 重建 `Map`;`create`:realpath → resolveByPath 撞 → reject;否则 `WorkspaceId(randomUUID())` + `table.put` + 建实体入缓存;`list()` 快照数组(过滤无效 sessionId 的投影在实体 getter 做);**无 delete 方法**(future work,与 session 级联一体落地) | -| `class WorkspaceEntity implements Workspace` | 构造持 registry/id/record;getter 投影;`mutate(fn)` 私有:`table.update(id, r => stampUpdatedAt(fn(r)))` 后原地换 record;`attachSession`:读 `sessionPersistence.list()` 找 header(或 inspect),cwd realpath ≠ path → reject;幂等(已在账 → no-op);`detachSession` 摘账(不动 session 文件);`status()`:`fs.access(path)` | -| 一致性口径 | ①账指向的 session 查无:**投影过滤**(getter 层)+ 下次 mutate 摘除;③双重账 load 检出 → throw;④missing-dir 只反映在 status() | -| invariant | 断言候选:缓存实体集合与 domain 表 key 集合一致(owned relationship:registry 缓存 vs 权威盘面)| - -## 7. Teammate 编成与节奏 - -| teammate | 包 | 开工条件 | 预估节奏 | -| --- | --- | --- | --- | -| (主线程) | W1 storage 枢纽 + §5 契约套件骨架 | 立即 | 首批落盘,随后进入 review/dispatcher 角色 | -| json-backend | W2a | W1 类型可编译即开工 | 分批落盘:atomic/format 先行,unit 次之,契约套件接入收尾 | -| sqlite-backend | W2b | 同上 | schema.ts 先行(照抄源已指明),unit 次之 | -| domain-layer | W3 + memory-backend helper | 同上 | spec/error 先行 → DomainImpl 写链 → 事件 → 契约套件(若主线程未完成则兼) | -| workspace-domain | W4 | W3 的 src 类型定型(不等其测试) | types/spec/paths 先行 → registry/entity → 测试 | - -协作规矩(照 conventions):分批落盘每批几分钟内、每批一句话回执;产出零落盘超 5 分钟报告;不混 commit 别人的在途文件;代码注释一律英文且只写非显然契约;干完不 kill 保持待命。commit 纪律:`--no-verify`,按包分刀(W1 一刀 → W2a/W2b/W3 各一刀 → W4 一刀 → 测试/文档尾刀),文档(本文件 + Agent Note 增量)住顶刀。 - -## 8. 主线程验收清单(每包合入前) - -- [ ] `pnpm run typecheck` 过(本期唯一硬门禁) -- [ ] 包结构齐:package.json(`@deepseek-ai/dsh-*`、ESM、cordis peerDep)、README、invariant 伴生(真断言或 explained empty) -- [ ] 接口与 Agent Note 一致;发现实现逼着改接口 → 停下来报主线程裁决(不擅改 Note) -- [ ] 测试文件落位正确(包级 tests/、`.spec.ts`),能跑多少跑多少,红的记台账不追修 -- [ ] session-persistence 包零 diff(`git status` 检查线) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 3c96ac1e85..f5526a81e0 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -10,8 +10,8 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`. 2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `..` (e.g. `'conversation.chat.toolview'`). -3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots` (children keys) & `PropsStore` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally. -4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) +3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots` (children keys) & `PropsStore` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally. +4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription. 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). 7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. @@ -70,6 +70,16 @@ Run the narrowest rung that covers what you touched; escalate only when the chan If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. +## New plugin package checklist + +Bringing up a new `packages/client/` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy): + +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. +2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; the `CLIENT_PACKAGES` roster in `apps/cli/src/web.ts`; an `apps/cli/package.json` dependency (`mountWebPlugins` resolves roster packages against the composing app's URL — a roster row that is not a dependency of `apps/cli` fails to mount). `pnpm-workspace.yaml` already globs `packages/*/*`. +3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. +4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case). +5. Rebuild the bundle (`pnpm --filter bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. + ## New component checklist 1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index dc9fbb85b8..569670c274 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,6 +2,10 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +## Keyless fixture + +Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. + ## Model Experience None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request. diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index c7e1ed5c68..1edaf9c7df 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,6 +8,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + WorkspaceApi, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e1e21dfd78..53c18dca5c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -10,7 +10,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, - ToolCallView, ToolEventView, ToolResultView, + ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId } from './api.ts' @@ -242,6 +242,20 @@ interface StreamConn { push(envelope: RpcRequest): void } +/** Deterministic fixture branches used by keyless Web assembly tests. */ +export interface FixtureOptions { + /** Start with no real Workspace or Session. */ + empty?: boolean + /** Reject every prompt before appending its user event. */ + rejectPrompt?: boolean + /** Publish the Session but fail its Workspace account write. */ + failWorkspaceAttach?: boolean + /** Publish and frame the Session, then throw instead of returning create. */ + dropSessionCreateResponse?: boolean + /** Order of the two successful create frames. */ + createFrameOrder?: 'session-first' | 'workspace-first' +} + /** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung * outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and * piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the @@ -286,10 +300,11 @@ class FxInbox implements StreamConn { /** * In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material). + * @param options - fixture branches for empty state and failure timing. * @returns an ApiProxy backed entirely by in-memory state — no host process, no network. */ -export function createFixtureApi(): ApiProxy { - const sessions: SessionSummary[] = [ +export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { + const sessions: SessionSummary[] = options.empty ? [] : [ { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' }, { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' }, @@ -298,6 +313,20 @@ export function createFixtureApi(): ApiProxy { const nextTurn = new Map([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 + let attachedSessions = options.empty ? 0 : 1 + // Workspace entities mirroring the host registry: the fixture sessions all + // live under one workspace, whose account carries them in attach order. + const wid = (raw: string): WorkspaceId => raw as WorkspaceId + const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() + const workspaces: WorkspaceView[] = options.empty ? [] : [{ + workspaceId: wid('fx-ws-fixture'), + path: '/tmp/fixture', + title: 'fixture', + sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')], + createdAt: fixtureEpoch, + updatedAt: fixtureEpoch, + }] + let nextWorkspace = 1 const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */ const pendingApprovalRpcId = mint() @@ -464,12 +493,71 @@ export function createFixtureApi(): ApiProxy { return { sessions: { list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), - create: (request) => { + create: async (request) => { + const workspace = request.payload.workspaceId === undefined + ? undefined + : workspaces.find(w => w.workspaceId === request.payload.workspaceId) + if (request.payload.workspaceId !== undefined && workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${request.payload.workspaceId}`, + details: { workspaceId: request.payload.workspaceId }, + }) + } + const cwd = workspace?.path ?? request.payload.cwd ?? '/tmp/fixture' + const requestedId = request.payload.sessionId + const attachWorkspace = (sessionId: SessionId): void => { + /* v8 ignore next -- callers enter only when a target Workspace exists. */ + if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return + workspace.sessionIds = [sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + const attachFailure = ( + sessionId: SessionId, + workspaceId: WorkspaceId, + ): Promise> => err(request, { + code: 'workspace-attach-failed' as const, + message: `fixture rejected Workspace attachment for ${sessionId}`, + details: { sessionId, workspaceId }, + }) + if (requestedId !== undefined) { + const existing = summaryOf(requestedId) + if (existing !== undefined) { + if (existing.cwd !== cwd) { + return err(request, { + code: 'session-conflict', + message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, + details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, + }) + } + if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) { + if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId) + attachWorkspace(requestedId) + } + return ok(request, { sessionId: requestedId }) + } + } const created: SessionSummary = { - sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture', + sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd, } sessions.push(created) - emitHost({ type: 'host/session-added', sessionId: created.sessionId }) + attachedSessions += 1 + const emitSession = (): void => { + emitHost({ type: 'host/session-added', sessionId: created.sessionId, cwd }) + } + if (workspace !== undefined && options.failWorkspaceAttach) { + emitSession() + return attachFailure(created.sessionId, workspace.workspaceId) + } + if (workspace !== undefined && options.createFrameOrder === 'workspace-first') { + attachWorkspace(created.sessionId) + emitSession() + } else { + emitSession() + if (workspace !== undefined) attachWorkspace(created.sessionId) + } + if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') return ok(request, { sessionId: created.sessionId }) }, history: async (request) => { @@ -489,6 +577,13 @@ export function createFixtureApi(): ApiProxy { if (summary === undefined) { return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) } + if (options.rejectPrompt) { + return err(request, { + code: 'agent-busy', + message: 'fixture: prompt rejected before acceptance', + details: { reason: 'fixture-prompt-rejection' }, + }) + } summary.updatedAt = Date.now() const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') if (mode === 'steer' && replays.has(id)) { @@ -524,7 +619,28 @@ export function createFixtureApi(): ApiProxy { }, }, host: { - describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }), + describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), + }, + workspace: { + list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), + create: (request) => { + const { path, name } = request.payload + const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` + const existing = workspaces.find(w => w.path === target) + if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) + const now = new Date().toISOString() + const created: WorkspaceView = { + workspaceId: wid(`fx-ws-${nextWorkspace++}`), + path: target, + title: name ?? target.split('/').filter(Boolean).at(-1) ?? target, + sessionIds: [], + createdAt: now, + updatedAt: now, + } + workspaces.unshift(created) + emitHost({ type: 'host/workspace-changed', workspace: { ...created } }) + return ok(request, { workspace: { ...created }, created: true }) + }, }, events: { async *mux(_request, signal) { @@ -606,7 +722,12 @@ export function createFixtureApi(): ApiProxy { * to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)). */ export class FixtureApiClient extends AbstractApiClient { - private readonly api = createFixtureApi() + private readonly api: ApiProxy + + constructor() { + super() + this.api = createFixtureApi(fixtureOptionsFromLocation()) + } protected doFetch(): Promise { throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable') @@ -634,6 +755,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) + case 'workspace.list': return this.api.workspace.list(request) + case 'workspace.create': return this.api.workspace.create(request) } } @@ -678,3 +801,16 @@ export class FixtureApiClient extends AbstractApiClient { return this.api.respond(message) } } + +/** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */ +function fixtureOptionsFromLocation(): FixtureOptions { + if (typeof location === 'undefined') return {} + const query = new URLSearchParams(location.search) + return { + empty: query.get('fixture') === 'empty', + rejectPrompt: query.get('fixturePrompt') === 'reject', + failWorkspaceAttach: query.get('fixtureAttach') === 'fail', + dropSessionCreateResponse: query.get('fixtureSessionCreate') === 'drop-response', + createFrameOrder: query.get('fixtureFrames') === 'workspace-first' ? 'workspace-first' : 'session-first', + } +} diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index b017d1c9e2..eb074e5011 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,10 +1,7 @@ /** - * Browser half of the wire consumer layer (contract: api-contracts v3 - * section 3; export inventory = v3 §3.2). The wire is this package's client - * half in its entirety — apply mounts ctx.connection: the shared api client - * plus the connection controller handle. Mode selection (?fixture) happens - * here so the rest of the client tree is mode-blind; the controller's sinks - * are wired by the runtime plugin (object layer), which injects this service. + * Browser wire client. The plugin selects fixture or HTTP transport, provides + * the shared API client, and lets the runtime object layer start the stream + * controller with its sinks. */ import type { Context } from 'cordis' import type { IApiClient } from './api.ts' @@ -16,16 +13,15 @@ import { WebApiClient } from './web-api-client.ts' export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, - ToolCallView, ToolResultView, + ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' export { RpcId, AbstractApiClient, transportError } from './api.ts' -// ---- Connection loop types (part of the ConnectionHandle.start contract; -// the controller class itself stays package-internal — apply owns the loop, -// tests reach it via src) ---- +// Connection loop types are public through ConnectionHandle.start; the +// controller remains package-internal. export type { ConnectionConfig, ConnectionSinks, ConnectionState } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 61d718b618..f06b2bd2f6 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,12 +1,6 @@ -/** - * Connection plugin, node half: the host end of the web transport. Registers - * the /api prefix route on the web server and bridges node:http requests to - * the transport-agnostic fetch-shaped api handler. The wire consumer layer - * lives in the client half (src/client/ — contract: api-contracts v3 - * section 3); consumers import the /client subpath. - */ +/** Host HTTP bridge for browser-client RPC. */ import type { Context } from 'cordis' -// Type-only route import; it also carries the httpServer Context merge. +// Activates the httpServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' @@ -14,16 +8,15 @@ import { bridge } from './http-bridge.ts' export { API_PATH } from './api-path.ts' -/** Cordis plugin name. */ +/** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Required services: the route registry and the api gateway. */ +/** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy'] /** - * Mount the /api transport: wrap the api gateway into a fetch handler and - * serve it under the /api prefix. - * @param ctx - host plugin context carrying httpServer and apiProxy. + * Mounts the API gateway under the browser transport prefix. + * @param ctx - Host plugin context. */ export function apply(ctx: Context): void { const apiHandler = toFetchHandler(ctx.apiProxy) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index af5743bf9a..faca82d5c3 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -71,6 +71,14 @@ export class FakeApiClient implements IApiClient { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), } + readonly workspace: IApiClient['workspace'] = { + list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))), + create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + created: true, + }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ac32955c36..16fa4b4ed6 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -5,7 +5,7 @@ * the hand-written fixture/host parallel implementations. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionId } from '../src/client/api.ts' +import type { SessionId, WorkspaceId } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts' import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts' @@ -87,7 +87,7 @@ describe('createFixtureApi', () => { await consuming if (!created.result.ok) throw new Error('create failed') const createdId = created.result.value.sessionId - expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }]) + expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, cwd: '/tmp/fixture' }]) const list = await api.sessions.list(req({})) if (!list.result.ok) throw new Error('list failed') expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true) @@ -259,6 +259,213 @@ describe('createFixtureApi', () => { const api = createFixtureApi() const response = await api.host.describe(req({})) expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } }) + const empty = await createFixtureApi({ empty: true }).host.describe(req({})) + expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) + }) + + it('workspace.list serves the resident account and create reuses on path collision', async () => { + const api = createFixtureApi() + const listed = await api.workspace.list(req({})) + if (!listed.result.ok) throw new Error('list failed') + expect(listed.result.value.items).toEqual([expect.objectContaining({ + workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', + sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], + })]) + // path collision → the existing entity comes back, created:false, no frame. + const reused = await api.workspace.create(req({ path: '/tmp/fixture' })) + if (!reused.result.ok) throw new Error('reuse failed') + expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) + }) + + it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const created = await api.workspace.create(req({ name: 'nova' })) + if (!created.result.ok) throw new Error('create failed') + expect(created.result.value.created).toBe(true) + expect(created.result.value.workspace).toMatchObject({ + path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [], + }) + await consuming + expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) + // path spelling falls back to the basename when no title/name rides along. + const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' })) + if (!pathOnly.result.ok) throw new Error('pathOnly failed') + expect(pathOnly.result.value.workspace.title).toBe('base') + // Degenerate spellings reach the impl unfiltered (the fixture carrier has + // no schema gate): both-absent falls back to the bucket dir, and a + // basename-less path serves as its own title. + const bare = await api.workspace.create(req({})) + if (!bare.result.ok) throw new Error('bare failed') + expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' }) + const rootPath = await api.workspace.create(req({ path: '/' })) + if (!rootPath.result.ok) throw new Error('rootPath failed') + expect(rootPath.result.value.workspace.title).toBe('/') + }) + + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + if (seen.length >= 2) abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) + if (!created.result.ok) throw new Error('create failed') + const id = created.result.value.sessionId + await consuming + // The session lands with the workspace's path as cwd, and the account + // write pushes the fresh workspace snapshot after session-added. + expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, cwd: '/tmp/fixture' }) + expect(seen[1]).toMatchObject({ + type: 'host/workspace-changed', + workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] }, + }) + }) + + it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => { + const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' }) + const initialSessions = await api.sessions.list(req({})) + const initialWorkspaces = await api.workspace.list(req({})) + expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) + expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) + + const made = await api.workspace.create(req({ name: 'nova' })) + if (!made.result.ok) throw new Error('workspace create failed') + const abort = new AbortController() + const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) + await new Promise(resolve => setTimeout(resolve, 10)) + const preallocated = sid('fx-preallocated') + const created = await api.sessions.create(req({ + workspaceId: made.result.value.workspace.workspaceId, + sessionId: preallocated, + })) + expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } }) + const frames = await framesPromise + expect(frames[0]).toMatchObject({ + type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] }, + }) + expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, cwd: made.result.value.workspace.path }) + + const retried = await api.sessions.create(req({ + workspaceId: made.result.value.workspace.workspaceId, + sessionId: preallocated, + })) + expect(retried.result).toEqual({ ok: true, value: { sessionId: preallocated } }) + const listed = await api.sessions.list(req({})) + if (!listed.result.ok) throw new Error('session list failed') + expect(listed.result.value.items.filter(item => item.sessionId === preallocated)).toHaveLength(1) + + const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' })) + expect(conflict.result).toMatchObject({ + ok: false, + error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } }, + }) + }) + + it('attaches an existing ungrouped Session to a matching Workspace', async () => { + const api = createFixtureApi() + const sessionId = sid('fx-existing-ungrouped') + await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({ + result: { ok: true, value: { sessionId } }, + }) + + await expect(api.sessions.create(req({ + sessionId, + workspaceId: 'fx-ws-fixture' as WorkspaceId, + }))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } }) + + const workspaces = await api.workspace.list(req({})) + if (!workspaces.result.ok) throw new Error('workspace list failed') + expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId) + }) + + it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => { + const api = createFixtureApi() + const listed = await api.sessions.list(req({})) + if (!listed.result.ok) throw new Error('session list failed') + const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha')) + if (existing === undefined) throw new Error('fixture Session missing') + delete existing.cwd + + const conflict = await api.sessions.create(req({ sessionId: existing.sessionId })) + expect(conflict.result).toEqual({ + ok: false, + error: { + code: 'session-conflict', + message: `session ${existing.sessionId} already uses no cwd`, + details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' }, + }, + }) + }) + + it('publishes an ungrouped Session when Workspace attachment fails', async () => { + const api = createFixtureApi({ failWorkspaceAttach: true }) + const sessionId = sid('fx-partial') + const created = await api.sessions.create(req({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId, + })) + expect(created.result).toMatchObject({ + ok: false, + error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } }, + }) + const listed = await api.sessions.list(req({})) + const workspaces = await api.workspace.list(req({})) + if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed') + expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) + expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId) + + const retried = await api.sessions.create(req({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId, + })) + expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + const afterRetry = await api.sessions.list(req({})) + if (!afterRetry.result.ok) throw new Error('list failed') + expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) + }) + + it('reconciles a dropped create response and can reject a prompt before acceptance', async () => { + const sessionId = sid('fx-lost-response') + const dropped = createFixtureApi({ dropSessionCreateResponse: true }) + await expect(Promise.resolve().then(() => dropped.sessions.create(req({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId, + })))).rejects.toThrow(/dropped session\.create response/) + const listed = await dropped.sessions.list(req({})) + const workspaces = await dropped.workspace.list(req({})) + if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed') + expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true) + expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId) + await expect(dropped.sessions.create(req({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId, + }))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } }) + + const rejecting = createFixtureApi({ empty: true, rejectPrompt: true }) + const real = await rejecting.sessions.create(req({ sessionId: sid('fx-rejected') })) + if (!real.result.ok) throw new Error('session create failed') + const prompt = await rejecting.sessions.prompt(req({ + sessionId: real.result.value.sessionId, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'keep me' }], + })) + expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) }) it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => { @@ -311,6 +518,7 @@ describe('createFixtureApi', () => { describe('FixtureApiClient (protocol-level fake carrier)', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => { @@ -346,6 +554,57 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) + expect((await client.workspace.list({})).result.ok).toBe(true) + const workspace = await client.workspace.create({ name: 'via-client' }) + if (!workspace.result.ok) throw new Error('workspace create failed') + expect(workspace.result.value.workspace.title).toBe('via-client') + }) + + it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { + vi.stubGlobal('location', { + search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first', + }) + const client = new FixtureApiClient() + await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) + const made = await client.workspace.create({ name: 'query-workspace' }) + if (!made.result.ok) throw new Error('workspace create failed') + const abort = new AbortController() + const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) + await new Promise(resolve => setTimeout(resolve, 10)) + const sessionId = sid('fx-query-session') + const created = await client.sessions.create({ + workspaceId: made.result.value.workspace.workspaceId, + sessionId, + }) + expect(created.result).toMatchObject({ ok: true, value: { sessionId } }) + const frames = await framesPromise + expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added']) + const rejected = await client.sessions.prompt({ + sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'retain' }], + }) + expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + }) + + it('maps attach-failure and dropped-response query scenarios', async () => { + vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' }) + const partial = new FixtureApiClient() + const partialResult = await partial.sessions.create({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId: sid('fx-query-partial'), + }) + expect(partialResult.result).toMatchObject({ + ok: false, + error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } }, + }) + + vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) + const dropped = new FixtureApiClient() + await expect(dropped.sessions.create({ + workspaceId: 'fx-ws-fixture' as WorkspaceId, + sessionId: sid('fx-query-dropped'), + })).rejects.toThrow(/dropped session\.create response/) }) it('fires onOpen at stream-iteration start and taps server-request full forms', async () => { diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts index 37e1c0cdb5..9dea9c4bd4 100644 --- a/packages/client/i18n/src/client/index.ts +++ b/packages/client/i18n/src/client/index.ts @@ -1,15 +1,10 @@ /** - * i18n plugin, browser half: namespace x locale dictionary registry with a - * bound translate function whose reference is stable (safe for inject - * surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries. - * Contract: api-contracts v3 section 8. + * Browser-side locale registry. Bound translation functions retain stable + * identity for injected consumers. */ import type { Context } from 'cordis' -// The snapshot-store engine lives in runtime (store relocation): framework -// data stores like this locale cell use it directly. The store carries no -// hook — a React consumer binds a selector hook via web-react's -// bindSnapshotSelector at its own seam (none exists today; the current -// consumers are translate() reads and test-side subscribe/set). +// Snapshot stores are framework-neutral; React consumers bind hooks at their +// rendering boundary. import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { en } from '../locales/en.ts' diff --git a/packages/client/i18n/src/index.ts b/packages/client/i18n/src/index.ts index 1e2de41ace..e759f1edc1 100644 --- a/packages/client/i18n/src/index.ts +++ b/packages/client/i18n/src/index.ts @@ -1,11 +1,4 @@ -/** - * i18n plugin, node half. Pure UI plugin: the empty apply exists so the - * plugin appears in the host cordis.yml / Loader (load and lifecycle follow - * the host; the browser half ships via exports["./client"], discovered - * through the package.json dshClient declaration). Everything else — - * I18nService, Translate, LocaleDict — lives in the client half; consumers - * import the /client subpath. Contract: api-contracts v3 section 8. - */ +/** Host loader entry for the browser implementation exported from `./client`. */ /** Host plugin body — no host-side behavior for the i18n plugin. */ export function apply(): void {} diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4fd5d15905..6b697cbed9 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,16 @@ # @deepseek-ai/dsh-client-runtime -Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, page-local Workspace Intent state, default-target derivation, and the cross-object New Session flow. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4. + +## Workspace and Session lists + +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. + +SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. + +## Session creation failures + +`SessionsService.create` accepts an optional caller-preallocated SessionId. It throws `SessionCreateError` on failure: `requestedSessionId` remains available after transport uncertainty, while `publishedSessionId` is set when `workspace-attach-failed` proves the Host published a real Session before attachment failed. For the New Session flow, the frontend Session object owns its retained prompt and advances it through attachment and send; a partially published Session keeps the same object and prompt while it appears as Ungrouped. ## Session title projection diff --git a/packages/client/runtime/src/client/contract/store.ts b/packages/client/runtime/src/client/contract/store.ts index ce4444cf36..7dc3b584ba 100644 --- a/packages/client/runtime/src/client/contract/store.ts +++ b/packages/client/runtime/src/client/contract/store.ts @@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void { } } -// ---- defineStore shell (slot terminal design §4) ---- -// The type authority is ui-slots' store family (create(scopeKey?) and -// clearPersisted() included); this module houses only the engine-backed -// implementation. The one engine-side widening left: instances expose the -// raw engine store for framework/test surfaces. +// ui-slots owns the contract; this module supplies the engine implementation. /** A live engine instance: the contract instance plus the raw engine store. */ export interface EngineStoreInstance> extends StoreInstance { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 33097d4573..830d1b8249 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,55 +1,38 @@ -/** - * Browser half: the whole runtime contract surface (api-contracts v3 §4) — - * SlotsService (declaration ledger + renderer seam + store axis, built-in - * 'root'), SessionsService (list store + current selection + scope tree + - * object layer), and the cordis Context/Events merges. apply mounts - * ctx.slots + ctx.sessions and wires the connection stream loop into the - * object layer. A static-arrival entry: the web shell bundles this module - * and mounts it through the host graph (module loading lives in - * @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader). - */ +/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' import { SessionsService } from './sessions/service.ts' import type { SessionListState } from './sessions/service.ts' +import { WorkspacesService } from './workspaces/service.ts' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' export { SlotsService } from './slots.ts' -// RootOwnerProps rides the 'root' SlotMap row (both migrated here from -// ui-layout: the framework slot is declared by the framework package). export type { RootOwnerProps } from './slots.ts' -export { SessionsService, scopeOf } from './sessions/service.ts' +export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' +export { WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' -// The snapshot-store engine lives here since the store migration (the data -// layer owns its substrate; web-react is React glue only). The './client' -// main export is the single serving door — no store subpath. +export type { SessionIntentListSnapshot, SessionListPhase } from './sessions/manager.ts' +export type { WorkspaceListPhase } from './workspaces/manager.ts' +export type { WorkspaceListState } from './workspaces/service.ts' +export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +// Runtime owns the snapshot store; web-react only binds it to React. export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot, - RunningToolCall, SteeringMessageNode, - ToolResultNode, UnknownSurfaceNode, UserMessageNode, + AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode, + ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, + SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' -// PendingWait is a value export: tests construct fixture waits directly. export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -// ---- Narrowed aliases (the single narrowing point of the slot type chain: -// ui-slots/web-react stay generic and dependency-inverted; the client-tree -// concrete types live here, where their subjects live) ---- - -/** - * The client cordis context face: the base Context plus the service keys - * this package's declaration merge contributes (slots/sessions/loader) and - * every later plugin's merge. A plain alias — the merges land on Context - * itself inside the client program; the name marks intent at consumer seams. - */ +/** Client-side Cordis context after declaration merging. */ export type ClientContext = Context /** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */ @@ -69,15 +52,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * every session-scope slot component receives these from the framework. */ interface SessionStandardProps { - /** Selector hook over this session's conversation snapshot. */ useSession: SnapshotSelectorHook /** The framework-resolved session id (owners never pass it). */ sessionId: SessionId } - /** Global standard kit, real members: the session-list hook every slot component receives. */ + /** Props injected into every global slot component. */ interface GlobalStandardProps { - /** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */ useSessions: SnapshotSelectorHook + /** Selector hook over real Workspaces and their independent baseline lifecycle. */ + useWorkspaces: SnapshotSelectorHook } } @@ -93,24 +76,31 @@ declare module 'cordis' { interface Context { slots: import('./slots.ts').SlotsService sessions: import('./sessions/service.ts').SessionsService + workspaces: import('./workspaces/service.ts').WorkspacesService } } /** Required services: the wire handle mounted by the connection plugin. */ export const inject = ['connection'] -/** - * Client plugin body: mount slots + sessions, start the stream loop. - * @param ctx - client cordis context. +/** Mounts the browser runtime services and connection stream. + * @param ctx - Client Cordis context. */ export function apply(ctx: Context): void { ctx.plugin(SlotsService) const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionsService(ctx, connection.api) + const workspaces = new WorkspacesService(ctx, connection.api, sessions) const loop = connection.start({ - onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) }, - onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) }, - onConnected: () => { sessions.manager.handleConnected() }, + onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) }, + onHostEnvelope: (envelope) => { + sessions.handleHostEnvelope(envelope) + workspaces.handleHostEnvelope(envelope) + }, + onConnected: () => { + sessions.handleConnected() + workspaces.handleConnected() + }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') } diff --git a/packages/client/runtime/src/client/ordered-baseline.ts b/packages/client/runtime/src/client/ordered-baseline.ts new file mode 100644 index 0000000000..b7fdcd545e --- /dev/null +++ b/packages/client/runtime/src/client/ordered-baseline.ts @@ -0,0 +1,43 @@ +/** + * Merge an authoritative baseline without moving identities already visible to + * the client. Baseline-only identities are inserted relative to the nearest + * following known identity; identities absent from the baseline are removed. + * + * @param current - the established client order. + * @param baseline - the latest authoritative rows. + * @param keyOf - stable identity selector. + * @returns baseline-valued rows with the established relative order retained. + */ +export function mergeOrderedBaseline( + current: readonly T[], + baseline: readonly T[], + keyOf: (value: T) => unknown, +): T[] { + const baselineByKey = new Map() + for (const value of baseline) baselineByKey.set(keyOf(value), value) + + const merged = current + .map(value => baselineByKey.get(keyOf(value))) + .filter((value): value is T => value !== undefined) + const mergedKeys = new Set(merged.map(keyOf)) + + for (let index = 0; index < baseline.length; index++) { + const value = baseline[index] + /* v8 ignore next -- dense-array guard: index is bounded by baseline.length. */ + if (value === undefined || mergedKeys.has(keyOf(value))) continue + let insertion = merged.length + for (let following = index + 1; following < baseline.length; following++) { + const candidate = baseline[following] + /* v8 ignore next -- dense-array guard: following is bounded by baseline.length. */ + if (candidate === undefined) continue + const known = merged.findIndex(item => keyOf(item) === keyOf(candidate)) + if (known !== -1) { + insertion = known + break + } + } + merged.splice(insertion, 0, value) + mergedKeys.add(keyOf(value)) + } + return merged +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 08b80f2a26..78d1eeabf5 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,7 +4,9 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { + RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId, +} from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' /** Assistant content blocks sorted by what the UI cares about @@ -149,12 +151,58 @@ export interface PartialAssistant { /** History-open lifecycle of a Session window. */ export type OpenState = 'cold' | 'loading' | 'open' | 'error' +/** + * Input-area shape of an OPEN session, derived at snapshot assembly (the one + * place that knows the predicate — consumers switch, never re-derive): + * + * - `blank`: no activity ever (no nodes, no partial, not running, no pending + * waits, no prompt attempt) — the UI renders the blank-session guidance + * hero. + * - `engaging`: the first prompt was initiated but no content landed yet — + * the UI holds the composer through the accept → running → first-event + * frames. Entered synchronously before prompt()'s first await. + * - `active`: content exists (nodes, partial, running turn, or pending + * waits) — the ordinary conversation view. + * + * Monotone within a session object: blank → engaging → active, no returns. + * A failed first prompt stays `engaging` (composer + error strip — retry + * semantics; bouncing back to the hero would discard the error context). + * Sessions whose window is not open (`loading`/`error`) are outside phase + * jurisdiction: consumers branch on {@link ConversationSnapshot.openState} + * first (phase still reports `active`-ish facts but must not be rendered). + */ +export type ComposerPhase = 'blank' | 'engaging' | 'active' + /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ export interface PromptError { op: 'send' | 'stop' error: RpcError } +/** Workspace target of a frontend-only Session. */ +export type SessionIntentTarget = + | { kind: 'workspace'; workspaceId: WorkspaceId } + | { kind: 'workspace-intent' } + +/** Publication state owned by a frontend Session before it joins the Host. */ +export interface SessionIntentSnapshot { + target: SessionIntentTarget + phase: 'ready' | 'connecting' + error?: { step: 'session'; message: string } +} + +/** One editable prompt retained by its Session until the Host accepts it. */ +export interface PendingPrompt { + text: string + phase: 'editing' | 'sending' | 'failed' + /** Failed prerequisite retried before sending, or the send itself. */ + retry: 'connect' | 'send' + /** Workspace needed when retrying Session attachment. */ + workspaceId?: WorkspaceId + /** Last failure diagnostic, absent while editing or sending. */ + error?: string +} + /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId @@ -166,6 +214,8 @@ export interface ConversationSnapshot { runningCalls: readonly RunningToolCall[] pending: readonly PendingInteraction[] running: boolean + /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */ + composerPhase: ComposerPhase /** Set after host/session-removed; the UI grays out and disables input. */ removed: boolean openState: OpenState @@ -173,5 +223,9 @@ export interface ConversationSnapshot { hasMore: boolean loadingOlder: boolean promptError: PromptError | null + /** Frontend-only publication state; null for a Host-connected Session. */ + intent: SessionIntentSnapshot | null + /** Session-owned editable prompt waiting for connection, attachment, or send. */ + pendingPrompt: PendingPrompt | null lastAgentError: string | null } diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 23b35f86bf..0f40d9bf2a 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -24,10 +24,10 @@ export interface CallIndexEntry { callView: ToolCallView | null } -/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch). - * 'noop/padding' is not a real event type on purpose: a genuine type with fake data would - * surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one - * place a synthetic event enters the window). */ +/** Non-surface sentinel used to preserve paged-window sequence offsets. + * `noop/padding` is deliberately not a real event type, so it cannot acquire + * surface behavior; this cast is the only synthetic event entry point. + */ function paddingEvent(seq: number): SessionEvent { return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent } diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index c6bd572ea7..3fd9af5d65 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -1,6 +1,6 @@ // flattenLineage: summaries -> flat list with lineage indentation (pure function). -// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage -// degrades to root level; cycles fail soft and emit as roots. +// The input order is authoritative; lineage only makes each child adjacent to its parent. +// Orphaned lineage degrades to root level; cycles fail soft and emit as roots. import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' @@ -22,8 +22,9 @@ export interface SessionListEntry { } /** - * summaries -> flat list with lineage indentation (pure; roots by updatedAt - * desc, DFS children in the same order, orphans degrade to roots). + * Summaries -> flat list with lineage indentation. Root and sibling order + * follows the established input order; this projection never re-sorts a + * hydrated list from mutable timestamps. * @param summaries - the host's session.list items. * @returns display rows in render order. */ @@ -43,9 +44,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess } } - const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt - roots.sort(byUpdatedDesc) - const out: SessionListEntry[] = [] const visited = new Set() const walk = (s: TitledSessionSummary, depth: number): void => { @@ -57,7 +55,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess out.push({ ...s, depth }) const kids = children.get(s.sessionId) if (kids === undefined) return - kids.sort(byUpdatedDesc) for (const kid of kids) walk(kid, depth + 1) } for (const root of roots) walk(root, 0) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b65935a97d..907fc961f6 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -2,22 +2,51 @@ // dispatch entry + list state, constructed and held by SessionsService (one per client runtime). // List data never enters zustand; React connects via subscribe/getListSnapshot. -import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' +import { mergeOrderedBaseline } from '../ordered-baseline.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import { Notifier } from './notifier.ts' import { Session } from './session.ts' +import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation.ts' + +/** + * List arrival lifecycle, orthogonal to the pull-activity `state` axis: + * `pending` (no successful pull yet — an empty items array means "nothing + * arrived", not "nothing exists") → `ready` (at least one pull landed). + * Monotone: `ready` never steps back — later pull failures and reconnect + * re-pulls ride the `state`/`error` axis, which is where failure is modeled + * (no `error` phase here; that would duplicate `state`). + */ +export type SessionListPhase = 'pending' | 'ready' + +/** Session-owned frontend Intent projected into the global list snapshot. */ +export interface SessionIntentListSnapshot extends SessionIntentSnapshot { + sessionId: SessionId + prompt: string +} /** Immutable session-list snapshot for useSessionList. */ export interface SessionListSnapshot { items: readonly SessionListEntry[] + /** Selected real or frontend-only Session id. */ + current: SessionId | undefined + /** Sole page-local frontend Session projection; its state remains owned by Session. */ + intent: SessionIntentListSnapshot | undefined state: 'idle' | 'loading' | 'error' + /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ + phase: SessionListPhase error: RpcError | null } +type SessionListMutation = + | { kind: 'upsert'; summary: SessionSummary } + | { kind: 'remove'; sessionId: SessionId } + | { kind: 'status'; sessionId: SessionId; running: boolean } + /** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */ const PENDING_BUFFER_CAP = 32 @@ -39,8 +68,16 @@ export class SessionManager { private readonly titleSnapshots = new Map() private summaries: SessionSummary[] = [] private listState: 'idle' | 'loading' | 'error' = 'idle' + /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */ + private listPhase: SessionListPhase = 'pending' private listError: RpcError | null = null private listInflight: Promise | null = null + /** Mutations arriving after a list request starts are replayed over its response. */ + private listMutations: SessionListMutation[] | null = null + + private selected: SessionId | undefined + private intentSessionId: SessionId | undefined + private stopIntentWatch: (() => void) | undefined private listSnapshotCache: SessionListSnapshot /** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry @@ -52,10 +89,90 @@ export class SessionManager { this.listSnapshotCache = this.buildListSnapshot() }) - constructor(private readonly api: IApiClient) { + /** + * @param api - shared wire client. + * @param restoredSelection - persisted real-Session selection candidate. + */ + constructor( + private readonly api: IApiClient, + restoredSelection?: SessionId, + ) { + this.selected = restoredSelection this.listSnapshotCache = this.buildListSnapshot() } + // ---- Selection and client-local intents ---- + + /** + * Select a real Session and discard the unmaterialized intent. + * @param sessionId - listed real Session id. + */ + select(sessionId: SessionId): void { + if (!this.summaries.some(summary => summary.sessionId === sessionId)) { + throw new Error(`sessions.select: unknown session ${sessionId}`) + } + this.discardIntent() + this.selected = sessionId + this.notifier.notifyNow() + } + + /** Clear selection and abandon any frontend-only Session. */ + clearSelection(): void { + this.discardIntent() + this.selected = undefined + this.notifier.notifyNow() + } + + /** + * Start a frontend Session against a real or still-local Workspace target. + * @param target - real Workspace or the WorkspacesService-owned local target. + * @param prompt - optional prompt retained when retargeting from a picker. + * @returns the frontend Session object that owns the Intent. + */ + startIntent(target: SessionIntentTarget, prompt = ''): Session { + this.discardIntent() + const sessionId = `client-session-${crypto.randomUUID()}` as SessionId + const session = this.createSession(sessionId, { target, prompt }) + this.sessions.set(sessionId, session) + this.intentSessionId = sessionId + this.selected = sessionId + this.stopIntentWatch = session.subscribe(() => { + if (this.intentSessionId !== sessionId) return + if (session.getSnapshot().intent === null) { + this.intentSessionId = undefined + this.stopIntentWatch?.() + this.stopIntentWatch = undefined + } + this.notifier.markDirty() + }) + this.notifier.notifyNow() + return session + } + + /** + * Resolve the active frontend Session Intent. + * @returns the active frontend Session, if one remains selected. + */ + getIntent(): Session | undefined { + return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId) + } + + /** + * Update the retained prompt of the active frontend Session. + * @param text - exact controlled-input value for the active frontend Session. + */ + updateIntent(text: string): void { + this.getIntent()?.updatePendingPrompt(text) + } + + private discardIntent(): void { + const session = this.getIntent() + this.intentSessionId = undefined + this.stopIntentWatch?.() + this.stopIntentWatch = undefined + session?.abandonIntent() + } + // ---- Instance management ---- /** @@ -67,7 +184,7 @@ export class SessionManager { get(sessionId: SessionId): Session { let session = this.sessions.get(sessionId) if (session === undefined) { - session = new Session(sessionId, this.api) + session = this.createSession(sessionId) this.sessions.set(sessionId, session) // Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open). const summary = this.summaries.find(s => s.sessionId === sessionId) @@ -82,6 +199,22 @@ export class SessionManager { return session } + private createSession( + sessionId: SessionId, + intent?: { target: SessionIntentTarget; prompt: string }, + ): Session { + return new Session(sessionId, this.api, { + ...(intent === undefined ? {} : { intent }), + onPublished: (published) => { + this.sessions.set(published.sessionId, published) + this.recordMutation({ + kind: 'upsert', + summary: { sessionId: published.sessionId, updatedAt: Date.now(), running: false }, + }) + }, + }) + } + // ---- List surface ---- /** Full refresh via session.list (single-flight: an in-flight call is reused). */ @@ -89,13 +222,21 @@ export class SessionManager { if (this.listInflight !== null) return this.listInflight this.listState = 'loading' this.listError = null + const established = this.summaries + const mutations: SessionListMutation[] = [] + this.listMutations = mutations this.notifier.markDirty() this.listInflight = (async () => { try { const { result } = await this.api.sessions.list({}) if (result.ok) { - this.summaries = result.value.items + let summaries = this.listPhase === 'pending' + ? result.value.items + : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) + for (const mutation of mutations) summaries = applyMutation(summaries, mutation) + this.summaries = summaries this.listState = 'idle' + this.listPhase = 'ready' // Push running bits down to instantiated Sessions (the list is the authoritative summary source). for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running) } else { @@ -108,6 +249,7 @@ export class SessionManager { /* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */ this.listError = folded.ok ? null : folded.error } finally { + this.listMutations = null this.listInflight = null this.notifier.markDirty() } @@ -118,18 +260,37 @@ export class SessionManager { /** * Contract session.create; on success merge into summaries immediately (no * wait for the next refresh). - * @param cwd - optional working directory for the new session. + * @param opts - target workspace or working directory, plus an optional caller-owned id. * @returns the create result. */ - async create(cwd?: string): Promise> { + async create( + opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, + ): Promise> { try { - const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd }) - if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) { - this.summaries = [ - { sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) }, - ...this.summaries, - ] - this.notifier.markDirty() + const payload = opts.workspaceId !== undefined + ? { workspaceId: opts.workspaceId, ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }) } + : { + ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), + ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), + } + const { result } = await this.api.sessions.create(payload) + if (result.ok) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, + ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + } }) + } else { + const publishedSessionId = workspaceAttachSessionId(result.error) + // Publication precedes attachment. The error's id is a real Session, + // so expose it immediately as Ungrouped while the caller keeps the + // prompt buffer and decides whether to retry attachment. + if (publishedSessionId !== undefined) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: publishedSessionId, + updatedAt: Date.now(), + running: false, + } }) + } } return result } catch (error) { @@ -137,6 +298,23 @@ export class SessionManager { } } + /** + * Insert-or-enrich a locally synthesized summary: a new id prepends; an + * existing entry only gains fields it lacks (the session-added frame and the + * create() echo race — whichever lands second must fill the placeholder's + * missing cwd/parentSessionId, never overwrite list-refresh data). + */ + private mergeSummary(summary: SessionSummary): void { + this.recordMutation({ kind: 'upsert', summary }) + } + + /** Apply immediately and retain for replay when a list response is in flight. */ + private recordMutation(mutation: SessionListMutation): void { + this.listMutations?.push(mutation) + this.summaries = applyMutation(this.summaries, mutation) + this.notifier.markDirty() + } + // ---- Subscription surface (for useSessionList) ---- /** @@ -216,31 +394,24 @@ export class SessionManager { const frame = envelope.payload switch (frame.type) { case 'host/session-added': { - if (!this.summaries.some(s => s.sessionId === frame.sessionId)) { - this.summaries = [ - { - sessionId: frame.sessionId, updatedAt: Date.now(), running: false, - ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), - }, - ...this.summaries, - ] - this.notifier.markDirty() - } + this.mergeSummary({ + sessionId: frame.sessionId, updatedAt: Date.now(), running: false, + ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), + ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), + }) + this.sessions.get(frame.sessionId)?.handlePublished() return } case 'host/session-removed': { - this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId) + this.recordMutation({ kind: 'remove', sessionId: frame.sessionId }) this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.titleSnapshots.delete(frame.sessionId) - this.notifier.markDirty() return } case 'host/session-status': { - this.summaries = this.summaries.map(s => - s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s) + this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running }) this.sessions.get(frame.sessionId)?.handleRunning(frame.running) - this.notifier.markDirty() return } case 'host/agent-error': { @@ -252,7 +423,7 @@ export class SessionManager { } } - /** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */ + /** After each connection generation: refresh the session baseline and rebuild opened windows. */ handleConnected(): void { void this.refreshList() for (const session of this.sessions.values()) void session.resync() @@ -281,6 +452,57 @@ export class SessionManager { } const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i]) if (!sameOrder) this.itemsCache = items - return { items: this.itemsCache, state: this.listState, error: this.listError } + const intentSession = this.getIntent() + const intentState = intentSession?.getSnapshot() + const intent = intentSession !== undefined + && intentState !== undefined && intentState.intent !== null && intentState.pendingPrompt !== null + ? { + sessionId: intentSession.sessionId, + ...intentState.intent, + prompt: intentState.pendingPrompt.text, + } + : undefined + const selected = this.selected + const current = selected !== undefined && ( + intent?.sessionId === selected || items.some(item => item.sessionId === selected) + ) ? selected : undefined + return { + items: this.itemsCache, + current, + intent, + state: this.listState, + phase: this.listPhase, + error: this.listError, + } } } + +/** Apply one list mutation without deriving display order. */ +function applyMutation(summaries: readonly SessionSummary[], mutation: SessionListMutation): SessionSummary[] { + switch (mutation.kind) { + case 'upsert': { + const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId) + if (existing === undefined) return [mutation.summary, ...summaries] + const filled: SessionSummary = { + ...existing, + ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}), + ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined + ? { parentSessionId: mutation.summary.parentSessionId } : {}), + } + if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId) return [...summaries] + return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) + } + case 'remove': + return summaries.filter(summary => summary.sessionId !== mutation.sessionId) + case 'status': + return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.running !== mutation.running + ? { ...summary, running: mutation.running } + : summary) + } +} + +/** Temporary source-plane bridge while the Host contract and client project build independently. */ +function workspaceAttachSessionId(error: RpcError): SessionId | undefined { + const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } } + return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined +} diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index d8a6f05762..845292a481 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -15,12 +15,16 @@ * survives frozen (read-only view) until the stage moves on. */ import type { Context, Fiber } from 'cordis' -import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import { SessionManager } from './manager.ts' +import type { + SessionIntentListSnapshot, SessionListPhase, +} from './manager.ts' import type { Session } from './session.ts' +import type { SessionIntentTarget } from './conversation.ts' /** Session list row projected from the host list RPC plus live stream increments. */ export interface SessionSummary { @@ -40,7 +44,36 @@ export interface SessionSummary { * the single useSessions standard hook reads list and selection together — * sidebar highlighting and SessionProvider share one fact source). */ -export interface SessionListState { ids: SessionId[]; byId: Record; current: SessionId | undefined } +export interface SessionListState { + ids: SessionId[] + byId: Record + current: SessionId | undefined + /** Frontend Session Intent projected from its owning Session object. */ + intent: SessionIntentListSnapshot | undefined + /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ + phase: SessionListPhase +} + +/** Structured session-create failure preserving partial publication identity. */ +export class SessionCreateError extends Error { + override readonly name = 'SessionCreateError' + /** Definitely published by Host before Workspace attachment failed. */ + readonly publishedSessionId: SessionId | undefined + + /** + * @param rpcError - Host business or folded transport error. + * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. + */ + constructor( + readonly rpcError: RpcError, + readonly requestedSessionId: SessionId | undefined, + ) { + super(`session create failed: ${rpcError.code}: ${rpcError.message}`) + this.publishedSessionId = rpcError.code === 'workspace-attach-failed' + ? rpcError.details.sessionId + : undefined + } +} /** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ export interface SessionBinding { @@ -64,6 +97,20 @@ export function scopeOf(ctx: Context): SessionId | undefined { /** Shared no-op plugin backing each session scope fiber. */ function sessionScope(): void {} +/** + * Workspace display title of a session cwd: the path's last non-empty + * segment (both separators accepted; trailing separators ignored), or '' + * for separator-only paths — callers own their fallback (session id, raw + * cwd, default-directory copy). The repo-wide single basename derivation — + * every surface naming a workspace (picker rows, toggle labels, list titles) + * calls this instead of re-splitting paths. + * @param cwd - workspace directory path. + * @returns basename title, or '' when no non-empty segment exists. + */ +export function workspaceTitleOf(cwd: string): string { + return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '' +} + /** * Display title projection: durable title, project directory basename, then * the raw id. @@ -71,8 +118,8 @@ function sessionScope(): void {} function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { if (title !== undefined) return title if (cwd !== undefined && cwd !== '') { - const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() - if (base !== undefined && base !== '') return base + const base = workspaceTitleOf(cwd) + if (base !== '') return base } return id } @@ -89,8 +136,8 @@ interface ScopeRecord { export class SessionsService { /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */ readonly list: SnapshotStore - /** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */ - readonly manager: SessionManager + /** The object-layer instance cluster and frame dispatch entry. */ + private readonly manager: SessionManager /** * Persisted selection cell (the durable half of `list.current`). Private on @@ -117,12 +164,14 @@ export class SessionsService { * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. */ - constructor(private readonly rootCtx: Context, private readonly api: IApiClient) { - this.manager = new SessionManager(api) + constructor(private readonly rootCtx: Context, api: IApiClient) { this.selection = createSnapshotStore<{ sessionId?: SessionId }>( {}, { persist: { name: 'dsh.sessions.current' } }) - this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined }) + this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId) + this.list = createSnapshotStore({ + ids: [], byId: {}, current: undefined, intent: undefined, phase: 'pending', + }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. this.manager.subscribe(() => { this.projectList() }) @@ -142,56 +191,88 @@ export class SessionsService { * @param id - session id (must exist in the list store). */ open(id: SessionId): void { - if (this.list.getSnapshot().byId[id] === undefined) { - throw new Error(`sessions.open: unknown session ${id}`) - } - this.selection.update((draft) => { draft.sessionId = id }) - this.list.update((draft) => { draft.current = id }) + this.manager.select(id) } /** * Clear the current selection so the layout shows the no-session empty - * state. Wipes the persisted selection too — a reload stays on empty until - * the user opens or starts a session. Staging holds the previous occupant - * across the blank (same masked-gap rule as a transient list miss). + * state (new-session affordance and the workspace preselection flow). + * Wipes the persisted selection too — a reload stays on empty until the + * user opens or starts a session. The staged scope keeps its frozen view + * per the masked-gap contract until the next open() moves the stage. */ clear(): void { - this.selection.set({}) - this.list.update((draft) => { draft.current = undefined }) + this.manager.clearSelection() + } + + /** + * Start or retarget the sole client-local Session intent. + * @param target - resolved real or frontend-only Workspace target. + * @param prompt - optional prompt retained across retargeting. + * @returns the frontend Session object that owns the Intent. + */ + startIntent(target: SessionIntentTarget, prompt = ''): Session { + return this.manager.startIntent(target, prompt) + } + + /** + * Resolve the active frontend Session Intent. + * @returns the active frontend Session object, if one exists. + */ + intent(): Session | undefined { + return this.manager.getIntent() + } + + /** + * Update the retained prompt of the active frontend Session. + * @param text - exact controlled-input value for the current Session Intent. + */ + updateIntent(text: string): void { + this.manager.updateIntent(text) + } + + /** + * Refresh the real Session baseline, reusing an in-flight pull. + * @returns completion of the current or newly started baseline pull. + */ + refresh(): Promise { + return this.manager.refreshList() + } + + /** + * Route a mux stream envelope into the Session object layer. + * @param envelope - validated mux stream envelope. + */ + handleMuxEnvelope(envelope: Parameters[0]): void { + this.manager.handleMuxEnvelope(envelope) + } + + /** + * Route a Host stream envelope into the Session object layer. + * @param envelope - validated Host stream envelope. + */ + handleHostEnvelope(envelope: Parameters[0]): void { + this.manager.handleHostEnvelope(envelope) + } + + /** Rebuild the Session baseline and every opened window after connection. */ + handleConnected(): void { + this.manager.handleConnected() } /** * Create a session on the host. - * @param opts - creation options (project directory). + * @param opts - target workspace or directory and an optional preallocated id. * @returns the new session id. + * @throws {SessionCreateError} with the requested id and, after an attach + * failure, the definitely published id. */ - async create(opts: { cwd?: string } = {}): Promise { - const result = await this.manager.create(opts.cwd) - if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`) + async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { + const result = await this.manager.create(opts) + if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) return result.value.sessionId } - /** - * Create a workspace folder under the host process cwd and a session in it. - * Name is a single path segment (no separators); the host mkdir runs inside - * session.create. Caller opens the returned id when it wants the session staged. - * @param name - workspace folder basename. - * @returns the new session id. - */ - async createWorkspace(name: string): Promise { - const trimmed = name.trim() - if (trimmed === '') throw new Error('sessions.createWorkspace: name is required') - if (/[/\\]/.test(trimmed)) { - throw new Error('sessions.createWorkspace: name must not contain path separators') - } - const { result } = await this.api.host.describe({}) - if (!result.ok) { - throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`) - } - const hostCwd = result.value.cwd.replace(/[/\\]+$/, '') - return this.create({ cwd: `${hostCwd}/${trimmed}` }) - } - /** * Resolve a session-scoped context view (use-and-discard). * @param id - session id. @@ -244,11 +325,12 @@ export class SessionsService { * failed one retries the next time current is touched). */ private followCurrent(): void { - const current = this.list.getSnapshot().current + const snapshot = this.list.getSnapshot() + const current = snapshot.current // A masked gap (current blanked while the selection's session is // transiently absent) holds the stage: tearing down on the gap would // destroy exactly the frozen scope the mask exists to preserve. - if (current === undefined || current === this.watched) return + if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return this.watched = current this.sweepDeferred() const record = this.resolve(current) @@ -291,8 +373,7 @@ export class SessionsService { fiber, ctx, binding: { sessionId: id, session, ctx }, - // Bare source form (store migration): the Session object IS the - // observable; the React side binds the useSession hook per cell. + // Session is the observable; React binds a selector hook at its own seam. cell: { sessionId: id, session }, } this.scopes.set(id, record) @@ -301,7 +382,7 @@ export class SessionsService { /** Project the manager's list snapshot into the store (title derivation is display-only). */ private projectList(): void { - const items = this.manager.getListSnapshot().items + const { items, current, intent, phase } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} for (const entry of items) { @@ -316,11 +397,13 @@ export class SessionsService { ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), } } - // current = the persisted selection, masked while its session is absent - // (falls to the empty state; resurfaces if the session returns). - const selected = this.selection.getSnapshot().sessionId - const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined - this.list.set({ ids, byId, current }) + const persisted = this.selection.getSnapshot().sessionId + if (intent?.sessionId === current) { + if (persisted !== undefined) this.selection.set({}) + } else if (current !== undefined && byId[current] !== undefined && persisted !== current) { + this.selection.set({ sessionId: current }) + } + this.list.set({ ids, byId, current, intent, phase }) this.pruneScopes(byId) } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 6b773e0903..396d0aa798 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,20 +1,18 @@ -// Session: wraps every contract call that needs a sessionId + all conversation state for this -// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once -// created, they keep consuming mux frames in the background; React connects directly via -// subscribe/getSnapshot. +// Sessions remain resident after creation so they continue consuming mux frames off-screen. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, - SessionId, ToolEventView, + SessionId, ToolEventView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import type { - ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall, + ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt, + PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' @@ -22,14 +20,18 @@ import { FoldAdapter } from './fold-adapter.ts' import { Notifier } from './notifier.ts' import { PartialAccumulator } from './partial.ts' -/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */ +/** Messages requested per history page. */ export const PAGE_MESSAGES = 50 +/** Optional frontend Intent and publication observer for a Session object. */ +export interface SessionOptions { + intent?: { target: SessionIntentTarget; prompt: string } + onPublished?(session: Session): void +} + /** - * Per-session state owner: event window + fold + partial, snapshot out via - * subscribe/getSnapshot (see the web client architecture RFC). Bare source - * only (store migration): the React machinery binds the per-cell useSession - * hook at its own seam — no selector hook member lives on the data layer. + * Owns a session's event window, derived conversation state, and observable + * snapshot. React bindings remain outside this data layer. */ export class Session implements ObservableSnapshot { // ---- Window and derived state (all private; the snapshot is the only read surface) ---- @@ -54,8 +56,7 @@ export class Session implements ObservableSnapshot { * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ private frozenNodes: ConversationNode[] = [] private pending = new Map() - // Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2, - // audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so + // Revision counters preserve array identity when derived content is unchanged, so // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every // tool card and pending card). Mutation sites bump the matching revision. partial needs no // counter — PartialAccumulator.toPartial already returns a cached reference when unchanged. @@ -66,12 +67,22 @@ export class Session implements ObservableSnapshot { private frozenRev = 0 private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null private running = false + /** + * Sticky send marker, private input of the composerPhase derivation: set + * synchronously before prompt()'s first await, never reset — the blank → + * engaging edge of the phase machine (see ComposerPhase). + */ + private promptAttempted = false private removed = false private promptError: PromptError | null = null + private intent: SessionIntentSnapshot | null + private pendingPrompt: PendingPrompt | null + private intentGeneration = 0 + private published: boolean private lastAgentError: string | null = null - /** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */ + /** Live events buffered during open/resync and stitched by sequence once history lands. */ private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] - /** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */ + /** Gap repair in flight; live events detour to the buffer until the tail page lands. */ private stitching = false /** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */ private subscribedLastSeq: number | null = null @@ -81,7 +92,23 @@ export class Session implements ObservableSnapshot { this.snapshotCache = this.buildSnapshot() }) - constructor(readonly sessionId: SessionId, private readonly api: IApiClient) { + /** + * @param sessionId - stable identity shared by the frontend Intent and Host entity. + * @param api - shared wire client. + * @param options - optional frontend-only initial state and publication observer. + */ + constructor( + readonly sessionId: SessionId, + private readonly api: IApiClient, + private readonly options: SessionOptions = {}, + ) { + this.intent = options.intent === undefined + ? null + : { target: options.intent.target, phase: 'ready' } + this.pendingPrompt = options.intent === undefined + ? null + : { text: options.intent.prompt, phase: 'editing', retry: 'send' } + this.published = options.intent === undefined this.snapshotCache = this.buildSnapshot() } @@ -96,6 +123,10 @@ export class Session implements ObservableSnapshot { async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> { this.promptError = null this.lastAgentError = null + // Synchronous, before the first await: the blank → engaging edge must be + // visible on the session area's very first frame when a caller sends + // ahead of navigation (first-send flow). + this.promptAttempted = true this.notifier.markDirty() let result: RpcResult<{ accepted: true }> try { @@ -110,6 +141,60 @@ export class Session implements ObservableSnapshot { return result } + /** + * Update this Session's retained prompt while it remains editable. + * @param text - exact controlled value of this Session's retained prompt. + */ + updatePendingPrompt(text: string): void { + const pending = this.pendingPrompt + if (pending === null || pending.phase === 'sending') return + this.pendingPrompt = { ...pending, text } + this.notifier.notifyNow() + } + + /** + * Connect this frontend Session to a real Workspace and flush its retained prompt. + * @param workspaceId - real Workspace target. + */ + connect(workspaceId: WorkspaceId): void { + const intent = this.intent + const pending = this.pendingPrompt + if (intent === null || intent.phase === 'connecting' || pending === null || pending.text.trim() === '') return + const connecting: SessionIntentSnapshot = { + target: { kind: 'workspace', workspaceId }, + phase: 'connecting', + } + const queued: PendingPrompt = { + ...pending, + phase: 'sending', + retry: 'connect', + workspaceId, + } + delete queued.error + this.intent = connecting + this.pendingPrompt = queued + this.notifier.notifyNow() + void this.flushPendingPrompt() + } + + /** Stop a superseded frontend Intent from automatically sending after publication. */ + abandonIntent(): void { + if (this.intent === null) return + this.intentGeneration += 1 + } + + /** Retry this Session's retained prompt from its failed prerequisite. */ + retryPendingPrompt(): void { + const pending = this.pendingPrompt + if (pending === null || pending.phase === 'sending' || pending.text.trim() === '') return + const sending: PendingPrompt = { ...pending, phase: 'sending' } + delete sending.error + this.pendingPrompt = sending + this.promptError = null + this.notifier.markDirty() + void this.flushPendingPrompt() + } + /** * Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot). * @returns the cancel result. @@ -277,6 +362,11 @@ export class Session implements ObservableSnapshot { this.notifier.markDirty() } + /** Mark that Host publication is known without resolving an uncertain local create response. */ + handlePublished(): void { + this.markPublished() + } + /** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */ handleRemoved(): void { this.removed = true @@ -292,8 +382,7 @@ export class Session implements ObservableSnapshot { this.notifier.markDirty() } - /** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed - * in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */ + /** No-op because session instances remain resident. */ dispose(): void {} // ---- 私有 ---- @@ -311,6 +400,112 @@ export class Session implements ObservableSnapshot { this.pendingRev++ } + /** Advance the retained prompt through Session attachment and submission. */ + private async flushPendingPrompt(): Promise { + const pending = this.pendingPrompt + if (pending?.phase === 'sending') { + const ready = pending.retry === 'connect' + ? await this.attachPendingPrompt(pending) + : pending + if (ready !== null) await this.sendPendingPrompt(ready) + } + } + + /** Complete the Host Session prerequisite and return the prompt's send step. */ + private async attachPendingPrompt(pending: PendingPrompt): Promise { + const workspaceId = pending.workspaceId + if (workspaceId === undefined) throw new Error('a Session attachment requires a Workspace id') + const originIntent = this.intent + const originGeneration = this.intentGeneration + let result: RpcResult<{ sessionId: SessionId }> + try { + result = (await this.api.sessions.create({ sessionId: this.sessionId, workspaceId })).result + } catch (error) { + result = transportError(error) + } + let ready: PendingPrompt | null = null + if (result.ok) { + ready = this.completePendingAttachment(pending, originIntent, originGeneration) + } else { + this.failPendingAttachment(pending, originIntent, originGeneration, result.error) + } + this.notifier.markDirty() + return ready + } + + /** Move a published Session to the send step unless its page intent was superseded. */ + private completePendingAttachment( + pending: PendingPrompt, + originIntent: SessionIntentSnapshot | null, + originGeneration: number, + ): PendingPrompt | null { + this.markPublished() + this.intent = null + this.promptAttempted = true + const superseded = originIntent !== null && originGeneration !== this.intentGeneration + const next: PendingPrompt = { + ...pending, + phase: superseded ? 'failed' : 'sending', + retry: 'send', + ...(superseded ? { error: 'Message was not sent because you navigated away.' } : {}), + } + if (!superseded) delete next.error + this.pendingPrompt = next + return superseded ? null : next + } + + /** Retain the prompt at the failed attachment step that owns the retry. */ + private failPendingAttachment( + pending: PendingPrompt, + originIntent: SessionIntentSnapshot | null, + originGeneration: number, + error: RpcError, + ): void { + const partiallyPublished = error.code === 'workspace-attach-failed' + if (partiallyPublished) { + this.markPublished() + this.intent = null + this.promptAttempted = true + } + const activeIntent = !partiallyPublished + && originIntent !== null + && originGeneration === this.intentGeneration + && this.intent === originIntent + if (activeIntent) { + this.intent = { + target: originIntent.target, + phase: 'ready', + error: { step: 'session', message: rpcErrorMessage(error) }, + } + this.pendingPrompt = { ...pending, phase: 'editing' } + } + if (!activeIntent && (partiallyPublished || originIntent === null) && this.pendingPrompt === pending) { + this.pendingPrompt = { ...pending, phase: 'failed', error: rpcErrorMessage(error) } + } + } + + /** Submit the retained prompt and keep it only when Host rejects the send. */ + private async sendPendingPrompt(pending: PendingPrompt): Promise { + const result = await this.prompt([{ type: 'text', text: pending.text.trim() }], 'queue') + if (this.pendingPrompt === pending) { + this.pendingPrompt = result.ok + ? null + : { + ...pending, + retry: 'send', + phase: 'failed', + error: rpcErrorMessage(result.error), + } + this.notifier.markDirty() + } + } + + private markPublished(): void { + if (this.published) return + this.published = true + this.options.onPublished?.(this) + } + /** @param generation - openGeneration at launch; every await re-checks it and a stale pass * drops all writes (resync superseded this open — its outcome belongs to a dead connection). */ private async doOpen(generation: number): Promise { @@ -527,21 +722,47 @@ export class Session implements ObservableSnapshot { if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } + const partial = this.partial?.toPartial() ?? null return { sessionId: this.sessionId, nodes, foldDegraded: degraded, - partial: this.partial?.toPartial() ?? null, + partial, runningCalls: this.callsCache.value, pending: this.pendingCache.value, running: this.running, + composerPhase: derivePhase( + nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0, + this.promptAttempted, + ), removed: this.removed, openState: this.openState, openError: this.openError, hasMore: this.hasMore, loadingOlder: this.loadingOlder, promptError: this.promptError, + intent: this.intent, + pendingPrompt: this.pendingPrompt, lastAgentError: this.lastAgentError, } } } + +function rpcErrorMessage(error: RpcError): string { + return `${error.code}: ${error.message}` +} + +/** + * The composerPhase judgment — the single site that knows the predicate + * (consumers switch on the result, never re-derive). Monotone per session + * object: `hasContent` only grows within a window and `promptAttempted` is + * sticky, so blank → engaging → active never steps back; a failed first + * prompt stays engaging (retry semantics — see ComposerPhase). + * @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits). + * @param promptAttempted - a prompt was initiated on this session object. + * @returns the derived phase. + */ +function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPhase { + if (hasContent) return 'active' + return promptAttempted ? 'engaging' : 'blank' +} diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 0930787e0a..2a19dcef56 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -235,13 +235,17 @@ export class SlotsService extends Service { } } - /** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */ + /** Build once after both object-layer services mount; session cells still resolve lazily. */ private hostFace(): SlotRendererHost { if (this._host !== undefined) return this._host const sessions = this.ctx.get('sessions') if (sessions === undefined) { throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first") } + const workspaces = this.ctx.get('workspaces') + if (workspaces === undefined) { + throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") + } // Identity-stable view: current rides the list snapshot (arbitrated), but // the provider consumes it as its own observable; one cached object keeps // the renderer's per-source hook cache stable. @@ -262,6 +266,7 @@ export class SlotsService extends Service { current, cell: id => sessions.cell(id), }, + workspaces: { list: workspaces.list }, } return this._host } diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts new file mode 100644 index 0000000000..6db4e54c79 --- /dev/null +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -0,0 +1,243 @@ +/** Workspace baseline, incremental-frame, and unary-action owner. */ + +import type { + HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' +import { mergeOrderedBaseline } from '../ordered-baseline.ts' +import { Notifier } from '../sessions/notifier.ts' +import { + Workspace, type WorkspaceCreateInput, type WorkspaceIntentSnapshot, +} from './workspace.ts' + +export type { WorkspaceIntentSnapshot } from './workspace.ts' + +/** Monotone workspace-list arrival lifecycle. */ +export type WorkspaceListPhase = 'pending' | 'ready' + +/** Immutable workspace-list snapshot. */ +export interface WorkspaceListSnapshot { + items: readonly WorkspaceView[] + /** The sole page-local Workspace intent; never persisted or sent over the Host stream. */ + intent: WorkspaceIntentSnapshot | undefined + state: 'idle' | 'loading' | 'error' + phase: WorkspaceListPhase + error: RpcError | null +} + +/** Workspace object cluster driven by one list baseline and changed-frame upserts. */ +export class WorkspaceManager { + private items: Workspace[] = [] + private intent: Workspace | undefined + private itemViewsSource: readonly Workspace[] | null = null + private itemViewsCache: readonly WorkspaceView[] = [] + private state: WorkspaceListSnapshot['state'] = 'idle' + private phase: WorkspaceListPhase = 'pending' + private error: RpcError | null = null + private inflight: Promise | null = null + private refreshFrames: WorkspaceView[] | null = null + private snapshotCache: WorkspaceListSnapshot + private readonly notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot() + }) + + /** @param api - shared wire client. */ + constructor(private readonly api: IApiClient) { + this.snapshotCache = this.buildSnapshot() + } + + /** + * Replace the current client-local Workspace intent object. + * @param name - directory/display name used if the intent is materialized. + * @returns the new intent snapshot. + */ + startIntent(name = 'workspace'): WorkspaceIntentSnapshot { + this.intent = new Workspace(this.api, { name }) + this.notifier.notifyNow() + return this.intent.getSnapshot().intent as WorkspaceIntentSnapshot + } + + /** Discard the current client-local Workspace intent. */ + discardIntent(): void { + if (this.intent === undefined) return + this.intent = undefined + this.notifier.notifyNow() + } + + /** + * Materialize the current Workspace intent through the ordinary Host create seam. + * A superseded intent is never cleared by an older completion. + * @returns the Host create result, or undefined when no intent exists. + */ + async materializeIntent(): Promise | undefined> { + const intent = this.intent + if (intent?.getSnapshot().intent?.phase !== 'ready') return undefined + const completion = intent.materialize() + if (completion === undefined) return undefined + this.notifier.notifyNow() + const result = await completion + if (result.ok) { + this.upsert(result.value.workspace, intent) + if (this.intent === intent) this.intent = undefined + } + this.notifier.markDirty() + return result + } + + /** + * Refresh from workspace.list. The first successful response establishes + * Host order; later responses update membership and values without moving + * identities already visible to the client. Frames arriving during the RPC + * are replayed over its response. + * @returns the shared in-flight refresh. + */ + refresh(): Promise { + if (this.inflight !== null) return this.inflight + this.state = 'loading' + this.error = null + const established = this.itemViews() + const frames: WorkspaceView[] = [] + this.refreshFrames = frames + this.notifier.markDirty() + this.inflight = (async () => { + try { + const { result } = await this.api.workspace.list({}) + if (result.ok) { + let items = this.phase === 'pending' + ? result.value.items + : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) + for (const workspace of frames) items = upsertWorkspace(items, workspace) + this.installViews(items) + this.state = 'idle' + this.phase = 'ready' + } else { + this.state = 'error' + this.error = result.error + } + } catch (error) { + this.state = 'error' + const folded = transportError(error) + /* v8 ignore next -- transportError always returns the failure branch. */ + this.error = folded.ok ? null : folded.error + } finally { + this.refreshFrames = null + this.inflight = null + this.notifier.markDirty() + } + })() + return this.inflight + } + + /** + * Create or resolve a real Workspace, then publish its returned snapshot + * without waiting for the changed frame. + * @param input - name under workspaceRoot or an existing absolute path. + * @returns the wire result. + */ + async create(input: WorkspaceCreateInput): Promise> { + const workspace = new Workspace(this.api, input) + const completion = workspace.materialize() + if (completion === undefined) throw new Error('a local Workspace must be materializable') + const result = await completion + if (result.ok) this.upsert(result.value.workspace, workspace) + return result + } + + /** + * Host-frame entry. Non-workspace frames are ignored so the runtime can + * fan one host stream out to both object managers. + * @param envelope - host stream envelope. + */ + handleHostEnvelope(envelope: RpcRequest): void { + if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) + } + + /** Re-pull the baseline after each connection generation. */ + handleConnected(): void { + void this.refresh() + } + + /** + * Subscribe to workspace snapshot invalidation. + * @param listener - snapshot invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener) + } + + /** + * Read the cached workspace snapshot after flushing pending notifications. + * @returns the cached workspace snapshot. + */ + getSnapshot(): WorkspaceListSnapshot { + this.notifier.ensureFresh() + return this.snapshotCache + } + + private buildSnapshot(): WorkspaceListSnapshot { + return { + items: this.itemViews(), + intent: this.intent?.getSnapshot().intent, + state: this.state, + phase: this.phase, + error: this.error, + } + } + + /** Upsert one Host view, optionally retaining the local object that materialized it. */ + private upsert(view: WorkspaceView, identity?: Workspace): void { + this.refreshFrames?.push(view) + const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) + if (identity !== undefined) { + this.items = index === -1 + ? [identity, ...this.items] + : this.items.map((item, position) => position === index ? identity : item) + } else if (index === -1) { + this.items = [new Workspace(this.api, view), ...this.items] + } else { + this.items[index]?.adopt(view) + this.items = [...this.items] + } + this.notifier.markDirty() + } + + private installViews(views: readonly WorkspaceView[]): void { + const existing = new Map( + this.items.flatMap((workspace) => { + const view = workspace.getSnapshot().view + return view === undefined ? [] : [[view.workspaceId, workspace] as const] + }), + ) + const installed = new Map() + for (const view of views) { + const duplicate = installed.get(view.workspaceId) + if (duplicate !== undefined) { + duplicate.adopt(view) + continue + } + const workspace = existing.get(view.workspaceId) ?? new Workspace(this.api, view) + workspace.adopt(view) + installed.set(view.workspaceId, workspace) + } + this.items = [...installed.values()] + } + + private itemViews(): readonly WorkspaceView[] { + if (this.itemViewsSource === this.items) return this.itemViewsCache + this.itemViewsSource = this.items + this.itemViewsCache = this.items.flatMap((workspace) => { + const view = workspace.getSnapshot().view + return view === undefined ? [] : [view] + }) + return this.itemViewsCache + } +} + +/** Known ids retain their position; a newly created Workspace enters first. */ +function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceView): WorkspaceView[] { + const index = items.findIndex(item => item.workspaceId === workspace.workspaceId) + return index === -1 + ? [workspace, ...items] + : items.map((item, position) => position === index ? workspace : item) +} diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts new file mode 100644 index 0000000000..854c53a75f --- /dev/null +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -0,0 +1,164 @@ +/** WorkspacesService projects the Workspace object manager for UI consumers. */ + +import type { Context } from 'cordis' +import type { + IApiClient, RpcError, WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import type { SnapshotStore } from '../contract/store.ts' +import { createSnapshotStore } from '../contract/store.ts' +import type { SessionsService } from '../sessions/service.ts' +import { WorkspaceManager, type WorkspaceIntentSnapshot, type WorkspaceListPhase } from './manager.ts' + +/** Workspace list plus the two-baseline readiness and default-target projection. */ +export interface WorkspaceListState { + items: readonly WorkspaceView[] + /** Sole client-local Workspace projection; its state remains owned by Workspace. */ + intent: WorkspaceIntentSnapshot | undefined + state: 'idle' | 'loading' | 'error' + phase: WorkspaceListPhase + error: RpcError | null + /** True only after both workspace.list and session.list have succeeded. */ + baselinesReady: boolean + /** Most recently active Workspace, derived without changing `items` order. */ + recentWorkspaceId: WorkspaceId | undefined +} + +/** Real Workspace object layer and Host actions. */ +export class WorkspacesService { + /** UI-facing immutable projection; the manager remains wire truth. */ + readonly list: SnapshotStore + /** Workspace baseline and frame owner. */ + private readonly manager: WorkspaceManager + private initialSessionResolved = false + private composingIntent = false + + /** + * @param ctx - client root context. + * @param api - shared wire client. + * @param sessions - lower-level Session service used for recency and cross-domain intent orchestration. + */ + constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) { + this.manager = new WorkspaceManager(api) + this.list = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'pending', error: null, + baselinesReady: false, recentWorkspaceId: undefined, + }) + this.manager.subscribe(() => { if (!this.composingIntent) this.project() }) + this.sessions.list.subscribe(() => { if (!this.composingIntent) this.project() }) + ctx.reflect.provide('workspaces', this, undefined) + } + + /** + * Start the sole Session intent, resolving the default Workspace here. + * @param workspaceId - optional explicit real Workspace target. + * @param prompt - optional prompt retained while retargeting. + */ + startSession(workspaceId?: WorkspaceId, prompt = ''): void { + const snapshot = this.list.getSnapshot() + const resolved = workspaceId ?? snapshot.recentWorkspaceId ?? snapshot.items[0]?.workspaceId + this.composingIntent = true + try { + if (resolved === undefined) { + this.manager.startIntent() + this.sessions.startIntent({ kind: 'workspace-intent' }, prompt) + } else { + this.manager.discardIntent() + this.sessions.startIntent({ kind: 'workspace', workspaceId: resolved }, prompt) + } + } finally { + this.composingIntent = false + this.project() + } + } + + /** Connect the current frontend Workspace and Session, then flush the Session-owned prompt. */ + sendSession(): void { + const session = this.sessions.intent() + const target = session?.getSnapshot().intent?.target + if (session === undefined || target === undefined) return + if (target.kind === 'workspace') { + session.connect(target.workspaceId) + return + } + if (session.getSnapshot().pendingPrompt?.text.trim() === '') return + void this.manager.materializeIntent().then((result) => { + if (this.sessions.intent() !== session) return + if (result?.ok) { + session.connect(result.value.workspace.workspaceId) + } + }) + } + + /** + * Create a Workspace by name or register an existing path. + * @param input - exactly one Host create spelling. + * @returns the created or idempotently resolved Workspace. + */ + async create(input: { name: string } | { path: string }): Promise { + const result = await this.manager.create(input) + if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + + /** + * Refresh the workspace baseline, reusing an in-flight pull. + * @returns completion of the current or newly started workspace baseline pull. + */ + refresh(): Promise { + return this.manager.refresh() + } + + /** + * Route a Host stream envelope into the Workspace object layer. + * @param envelope - validated Host stream envelope. + */ + handleHostEnvelope(envelope: Parameters[0]): void { + this.manager.handleHostEnvelope(envelope) + } + + /** Rebuild the Workspace baseline after connection. */ + handleConnected(): void { + this.manager.handleConnected() + } + + private project(): void { + const workspace = this.manager.getSnapshot() + const sessions = this.sessions.list.getSnapshot() + if (workspace.intent !== undefined && sessions.intent?.target.kind !== 'workspace-intent') { + this.manager.discardIntent() + return + } + const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready' + this.list.set({ + ...workspace, + baselinesReady, + recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined, + }) + if (!this.initialSessionResolved && baselinesReady) { + this.initialSessionResolved = true + if (sessions.current === undefined && sessions.intent === undefined) this.startSession() + } + } +} + +/** Stable tie-breaking follows Host Workspace order. */ +function recentWorkspace( + workspaces: readonly WorkspaceView[], + sessions: ReturnType['byId'], +): WorkspaceId | undefined { + let selected: WorkspaceId | undefined + let selectedTime = Number.NEGATIVE_INFINITY + for (const workspace of workspaces) { + let latest = Number.NEGATIVE_INFINITY + for (const sessionId of workspace.sessionIds) { + const session = sessions[sessionId] + if (session !== undefined) latest = Math.max(latest, session.updatedAt) + } + if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt) + if (selected === undefined || latest > selectedTime) { + selected = workspace.workspaceId + selectedTime = latest + } + } + return selected +} diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts new file mode 100644 index 0000000000..afa4dd65b6 --- /dev/null +++ b/packages/client/runtime/src/client/workspaces/workspace.ts @@ -0,0 +1,143 @@ +/** React-free Workspace entity with a client-local materialization lifecycle. */ + +import type { + IApiClient, RpcResult, WorkspaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { ObservableSnapshot } from '../contract/store.ts' +import { Notifier } from '../sessions/notifier.ts' + +/** Host input retained by a local Workspace until materialization succeeds. */ +export type WorkspaceCreateInput = { name: string } | { path: string } + +/** Observable state of a client-local Workspace intent. */ +export interface WorkspaceIntentSnapshot { + name: string + phase: 'ready' | 'creating' + error?: string +} + +/** A Workspace is either a local intent or a materialized Host view. */ +export interface WorkspaceSnapshot { + view: WorkspaceView | undefined + intent: WorkspaceIntentSnapshot | undefined +} + +interface WorkspaceIntent { + input: WorkspaceCreateInput + snapshot: WorkspaceIntentSnapshot +} + +/** + * Observable Workspace object whose identity survives Host materialization. + * Local instances retain their create input and failure state; materialized + * instances expose the latest Host view. + */ +export class Workspace implements ObservableSnapshot { + private view: WorkspaceView | undefined + private intent: WorkspaceIntent | undefined + private materialization: Promise> | null = null + private snapshotCache: WorkspaceSnapshot + private readonly notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot() + }) + + /** + * @param api - shared wire client. + * @param source - local create input or an existing Host Workspace view. + */ + constructor(private readonly api: IApiClient, source: WorkspaceCreateInput | WorkspaceView) { + if ('workspaceId' in source) { + this.view = source + } else { + this.intent = { + input: source, + snapshot: { name: intentName(source), phase: 'ready' }, + } + } + this.snapshotCache = this.buildSnapshot() + } + + /** + * Materialize this local Workspace through the Host create seam. + * Re-entry shares the in-flight completion; a materialized instance returns undefined. + * @returns the Host result, or undefined when this Workspace is already materialized. + */ + materialize(): Promise> | undefined { + if (this.materialization !== null) return this.materialization + const intent = this.intent + if (intent === undefined) return undefined + intent.snapshot = { name: intent.snapshot.name, phase: 'creating' } + this.notifier.notifyNow() + const completion = this.completeMaterialization(intent).finally(() => { + if (this.materialization === completion) this.materialization = null + }) + this.materialization = completion + return completion + } + + /** + * Adopt a Host view without replacing this Workspace object. + * An existing materialized identity accepts updates only for the same Workspace id. + * @param view - latest Host projection. + */ + adopt(view: WorkspaceView): void { + if (this.view !== undefined && this.view.workspaceId !== view.workspaceId) { + throw new Error('cannot adopt a different Workspace id') + } + this.view = view + this.intent = undefined + this.notifier.markDirty() + } + + /** + * Subscribe to Workspace snapshot invalidation. + * @param listener - snapshot invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener) + } + + /** + * Read the cached Workspace snapshot after flushing pending notifications. + * @returns the cached Workspace snapshot. + */ + getSnapshot(): WorkspaceSnapshot { + this.notifier.ensureFresh() + return this.snapshotCache + } + + private async completeMaterialization( + intent: WorkspaceIntent, + ): Promise> { + let result: RpcResult<{ workspace: WorkspaceView; created: boolean }> + try { + result = (await this.api.workspace.create(intent.input)).result + } catch (error) { + result = transportError(error) + } + if (this.intent !== intent) return result + if (result.ok) { + this.adopt(result.value.workspace) + } else { + intent.snapshot = { + name: intent.snapshot.name, + phase: 'ready', + error: `${result.error.code}: ${result.error.message}`, + } + this.notifier.markDirty() + } + return result + } + + private buildSnapshot(): WorkspaceSnapshot { + return { view: this.view, intent: this.intent?.snapshot } + } +} + +function intentName(input: WorkspaceCreateInput): string { + if ('name' in input) return input.name + const trimmed = input.path.replace(/[\\/]+$/, '') + return trimmed.split(/[\\/]/).pop() ?? input.path +} diff --git a/packages/client/runtime/src/index.ts b/packages/client/runtime/src/index.ts index b0d0f0a7c8..c1ea85d1e5 100644 --- a/packages/client/runtime/src/index.ts +++ b/packages/client/runtime/src/index.ts @@ -1,11 +1,4 @@ -/** - * Runtime plugin, node half. The implementation lives entirely in the client - * half (src/client/ — SlotsService, SessionsService + object layer, and the - * shell-held ClientLoader under ./loader); consumers import the /client or - * /loader subpaths. The empty apply exists so the plugin appears in the host - * Loader (lifecycle governance + dshClient discovery). Contract: - * api-contracts v3 section 4. - */ +/** Host loader entry for the browser runtime exported from `./client` and `./loader`. */ /** Host plugin body — no host-side behavior for the runtime plugin. */ export function apply(_ctx: unknown): void {} diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 0baa2e8237..14fede564d 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -1,5 +1,5 @@ /** - * Runtime plugin browser-half apply: slots + sessions mounting over the + * Runtime plugin browser-half apply: slots + object services mounting over the * connection handle, stream-loop sink wiring into the object layer, and the * fiber-scoped loop teardown. */ @@ -34,14 +34,17 @@ async function mount(): Promise { } describe('runtime client apply', () => { - it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => { + it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => { const bench = await mount() expect(bench.ctx.get('slots') !== undefined).toBe(true) // The built-in 'root' declaration ships with this package's SlotsService // (the SlotMap 'root' merge lives here since the slot-parity rework). expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' }) const sessions = bench.ctx.get('sessions') + const workspaces = bench.ctx.get('workspaces') expect(sessions !== undefined).toBe(true) + expect(workspaces !== undefined).toBe(true) + if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply') expect(bench.sinks).toBeDefined() // Frame sinks reach the object layer: a host session-added lands in the list store. @@ -51,6 +54,18 @@ describe('runtime client apply', () => { }) await Promise.resolve() expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new') + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r-workspace' as never, + payload: { + type: 'host/workspace-changed', + workspace: { + workspaceId: 'w-new', path: '/w/new', title: 'new', sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }, + } as never, + }) + await Promise.resolve() + expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') // Mux sink and onConnected route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) bench.sinks?.onConnected?.() diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 25f12c2b70..45efcf9e36 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -3,9 +3,23 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, + WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +/** Programmable-default workspace row (branded id, ISO-ish times). */ +function fakeWorkspace(id: string, over: Partial = {}): WorkspaceView { + return { + workspaceId: id as WorkspaceId, + path: '/f/ws', + title: 'ws', + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...over, + } +} + export interface Deferred { promise: Promise resolve(value: T): void @@ -74,6 +88,15 @@ export class FakeApiClient implements IApiClient { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), } + onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onWorkspaceCreate: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) + + readonly workspace: IApiClient['workspace'] = { + list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), + create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/runtime/tests/lineage.spec.ts b/packages/client/runtime/tests/lineage.spec.ts index 9ef959c4b9..1963f9c261 100644 --- a/packages/client/runtime/tests/lineage.spec.ts +++ b/packages/client/runtime/tests/lineage.spec.ts @@ -13,7 +13,7 @@ const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ }) describe('flattenLineage', () => { - it('sorts roots by updatedAt desc and expands children DFS with depth, children sorted too', () => { + it('keeps established root and sibling order while expanding children DFS with depth', () => { const out = flattenLineage([ s('old-root', 10), s('new-root', 30), @@ -22,7 +22,7 @@ describe('flattenLineage', () => { s('grandkid', 5, 'kid-new'), ]) expect(out.map(e => [e.sessionId, e.depth])).toEqual([ - ['new-root', 0], ['kid-new', 1], ['grandkid', 2], ['kid-old', 1], ['old-root', 0], + ['old-root', 0], ['new-root', 0], ['kid-old', 1], ['kid-new', 1], ['grandkid', 2], ]) }) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index d37ef6dbce..c532454224 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -57,7 +57,7 @@ describe('instances', () => { }) describe('list lifecycle', () => { - it('single-flights refreshList and lands items sorted through lineage flattening', async () => { + it('single-flights refreshList and preserves the Host baseline order', async () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise @@ -65,12 +65,33 @@ describe('list lifecycle', () => { const first = manager.refreshList() const second = manager.refreshList() expect(manager.getListSnapshot().state).toBe('loading') - gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] })) await Promise.all([first, second]) expect(api.callsOf('session.list')).toHaveLength(1) const snapshot = manager.getListSnapshot() expect(snapshot.state).toBe('idle') - expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) // updatedAt desc + expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) + }) + + it('replays incremental frames over hydration and never batch-reorders established ids', async () => { + const api = new FakeApiClient() + const first = deferred>>() + api.onList = () => first.promise + const manager = new SessionManager(api) + const hydration = manager.refreshList() + manager.handleHostEnvelope({ + rpcId: 'during-first' as never, + payload: { type: 'host/session-added', sessionId: S2 }, + }) + first.resolve(ok({ items: [summary(S1)] as never[] })) + await hydration + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) + + api.onList = () => Promise.resolve(ok({ + items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[], + })) + await manager.refreshList() + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) it('keeps the error in the list snapshot on failure', async () => { @@ -79,6 +100,26 @@ describe('list lifecycle', () => { const manager = new SessionManager(api) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) + // A failed pull does not step the arrival phase: still pending. + expect(manager.getListSnapshot().phase).toBe('pending') + }) + + it('phase steps pending → ready on the first successful pull and never returns', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + expect(manager.getListSnapshot().phase).toBe('pending') + await manager.refreshList() + expect(manager.getListSnapshot().phase).toBe('ready') + // Sticky across later failures: the pull-activity axis reports the error, + // the arrival phase holds. + api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) + await manager.refreshList() + expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' }) + // And across an empty re-pull (empty-with-ready = truly no sessions). + api.onList = () => Promise.resolve(ok({ items: [] as never[] })) + await manager.refreshList() + expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' }) + expect(manager.getListSnapshot().items).toEqual([]) }) it('merges create into the list immediately without waiting for a refresh', async () => { @@ -192,14 +233,14 @@ describe('remaining branches', () => { expect(session.getSnapshot().running).toBe(true) }) - it('create passes cwd through, folds transport throws, and skips the merge when already listed', async () => { + it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) const manager = new SessionManager(api) - await manager.create('/tmp/w') - expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w' }]) + await manager.create({ cwd: '/tmp/w', sessionId: S1 }) + expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) - await manager.create('/tmp/w') // same id returned: no duplicate row + await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row expect(manager.getListSnapshot().items).toHaveLength(1) api.onCreate = () => Promise.reject(new Error('create wire down')) expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } }) @@ -208,6 +249,42 @@ describe('remaining branches', () => { expect(await manager.create()).toMatchObject({ ok: false }) }) + it('publishes a real Ungrouped summary from workspace-attach-failed', async () => { + const api = new FakeApiClient() + api.onCreate = () => Promise.resolve(err({ + code: 'workspace-attach-failed', + message: 'published but unattached', + details: { sessionId: S1, workspaceId: 'w1' }, + } as never)) + const manager = new SessionManager(api) + const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) + expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) + expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') + }) + + it('reconciles a preallocated id after an ordinary transport failure', async () => { + const api = new FakeApiClient() + api.onCreate = () => Promise.reject(new Error('response lost')) + const manager = new SessionManager(api) + const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) + expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) + expect(manager.getListSnapshot().items).toEqual([]) + + manager.handleHostEnvelope({ + rpcId: 'published-later' as never, + payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' }, + }) + expect(manager.getListSnapshot().items).toEqual([ + expect.objectContaining({ sessionId: S1, cwd: '/w/one' }), + ]) + manager.handleHostEnvelope({ + rpcId: 'duplicate-frame' as never, + payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' }, + }) + expect(manager.getListSnapshot().items).toHaveLength(1) + }) + it('subscribe notifies on list changes and stops after unsubscribe', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) diff --git a/packages/client/runtime/tests/session-intents.spec.ts b/packages/client/runtime/tests/session-intents.spec.ts new file mode 100644 index 0000000000..c09bfa4ee4 --- /dev/null +++ b/packages/client/runtime/tests/session-intents.spec.ts @@ -0,0 +1,191 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import { SessionsService } from '../src/client/sessions/service.ts' +import { WorkspacesService } from '../src/client/workspaces/service.ts' +import { FakeApiClient, deferred, err, ok } from './fake-api.ts' + +const sid = (id: string): SessionId => id as SessionId +const wid = (id: string): WorkspaceId => id as WorkspaceId + +function workspace(id: string, sessionIds: SessionId[] = []): WorkspaceView { + return { + workspaceId: wid(id), + path: `/w/${id}`, + title: id, + sessionIds, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + } +} + +async function ready( + api: FakeApiClient, + workspaces: WorkspacesService, + sessions: SessionsService, + workspaceRows: WorkspaceView[], + sessionRows: { sessionId: SessionId; updatedAt: number; running: boolean }[] = [], +): Promise { + api.onWorkspaceList = () => Promise.resolve(ok({ items: workspaceRows as never[] })) + api.onList = () => Promise.resolve(ok({ items: sessionRows as never[] })) + await Promise.all([workspaces.refresh(), sessions.refresh()]) + await Promise.resolve() +} + +function services(api: FakeApiClient): { sessions: SessionsService; workspaces: WorkspacesService } { + const ctx = new Context() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + return { sessions, workspaces } +} + +function pendingPrompt(sessions: SessionsService, sessionId: SessionId) { + return sessions.binding(sessionId)?.session.getSnapshot().pendingPrompt +} + +describe('frontend Session and Workspace intents', () => { + it('resolves the initial intent into the most recently active Workspace', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + const old = workspace('old', [sid('s-old')]) + const recent = workspace('recent', [sid('s-recent')]) + await ready(api, workspaces, sessions, [old, recent], [ + { sessionId: sid('s-old'), updatedAt: 1, running: false }, + { sessionId: sid('s-recent'), updatedAt: 2, running: false }, + ]) + expect(sessions.list.getSnapshot().intent).toMatchObject({ + target: { kind: 'workspace', workspaceId: 'recent' }, + phase: 'ready', + }) + expect(workspaces.list.getSnapshot().intent).toBeUndefined() + }) + + it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + await ready(api, workspaces, sessions, []) + expect(workspaces.list.getSnapshot().intent).toMatchObject({ name: 'workspace', phase: 'ready' }) + sessions.updateIntent('first prompt') + api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('created'), created: true })) + api.onCreate = payload => Promise.resolve(ok({ + sessionId: (payload as { sessionId: SessionId }).sessionId, + })) + api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'prompt offline', details: {} })) + workspaces.sendSession() + await vi.waitFor(() => { + const sessionId = sessions.list.getSnapshot().current as SessionId + expect(pendingPrompt(sessions, sessionId)).toMatchObject({ + text: 'first prompt', phase: 'failed', retry: 'send', + }) + }) + expect(api.callsOf('workspace.create')).toEqual([{ name: 'workspace' }]) + const create = api.callsOf('session.create')[0] as { workspaceId: WorkspaceId; sessionId: SessionId } + expect(create.workspaceId).toBe('created') + expect(api.callsOf('session.prompt')).toEqual([{ + sessionId: create.sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'first prompt' }], + }]) + expect(workspaces.list.getSnapshot().intent).toBeUndefined() + }) + + it('turns Workspace attachment failure into a focused real Session and retries its prompt', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + const target = workspace('target') + await ready(api, workspaces, sessions, [target]) + sessions.updateIntent('keep this') + api.onCreate = (payload) => { + const sessionId = (payload as { sessionId: SessionId }).sessionId + return Promise.resolve(err({ + code: 'workspace-attach-failed', + message: 'attach rejected', + details: { sessionId, workspaceId: target.workspaceId }, + })) + } + workspaces.sendSession() + await vi.waitFor(() => { + const snapshot = sessions.list.getSnapshot() + expect(snapshot.intent).toBeUndefined() + expect(pendingPrompt(sessions, snapshot.current as SessionId)).toMatchObject({ + text: 'keep this', phase: 'failed', retry: 'connect', + }) + }) + const published = sessions.list.getSnapshot().current as SessionId + const session = sessions.binding(published)!.session + session.updatePendingPrompt('retry this') + api.onCreate = () => Promise.resolve(ok({ sessionId: published })) + session.retryPendingPrompt() + await vi.waitFor(() => { + expect(pendingPrompt(sessions, published)).toBeNull() + }) + expect(api.callsOf('session.prompt').at(-1)).toMatchObject({ + sessionId: published, + content: [{ type: 'text', text: 'retry this' }], + }) + }) + + it('does not send after navigation while Session creation is in flight', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + const target = workspace('target') + await ready(api, workspaces, sessions, [target]) + const gate = deferred>>() + api.onCreate = () => gate.promise + sessions.updateIntent('do not send yet') + workspaces.sendSession() + await vi.waitFor(() => { expect(api.callsOf('session.create')).toHaveLength(1) }) + const requested = (api.callsOf('session.create')[0] as { sessionId: SessionId }).sessionId + workspaces.startSession(target.workspaceId) + const replacement = sessions.list.getSnapshot().intent! + gate.resolve(ok({ sessionId: requested })) + await vi.waitFor(() => { + expect(pendingPrompt(sessions, requested)).toMatchObject({ + text: 'do not send yet', phase: 'failed', retry: 'send', + }) + }) + expect(api.callsOf('session.prompt')).toEqual([]) + expect(sessions.list.getSnapshot()).toMatchObject({ + current: replacement.sessionId, + intent: { sessionId: replacement.sessionId }, + }) + }) + + it('keeps a lost-response Intent and retries creation with its preallocated id', async () => { + const api = new FakeApiClient() + const { sessions, workspaces } = services(api) + const target = workspace('target') + await ready(api, workspaces, sessions, [target]) + sessions.updateIntent('preserve me') + api.onCreate = () => Promise.reject(new Error('response lost')) + workspaces.sendSession() + await vi.waitFor(() => { + expect(sessions.list.getSnapshot().intent?.error).toMatchObject({ step: 'session' }) + }) + const requested = sessions.list.getSnapshot().intent?.sessionId as SessionId + sessions.handleHostEnvelope({ + rpcId: 'published-later' as never, + payload: { type: 'host/session-added', sessionId: requested, cwd: target.path }, + }) + expect(sessions.list.getSnapshot()).toMatchObject({ + current: requested, + intent: { sessionId: requested, error: { step: 'session' } }, + }) + expect(sessions.intent()?.getSnapshot().pendingPrompt).toMatchObject({ + text: 'preserve me', phase: 'editing', + }) + + api.onCreate = payload => Promise.resolve(ok({ + sessionId: (payload as { sessionId: SessionId }).sessionId, + })) + workspaces.sendSession() + await vi.waitFor(() => { + expect(api.callsOf('session.create')).toHaveLength(2) + expect(api.callsOf('session.prompt')).toHaveLength(1) + expect(sessions.list.getSnapshot()).toMatchObject({ current: requested, intent: undefined }) + expect(pendingPrompt(sessions, requested)).toBeNull() + }) + expect(api.callsOf('session.create').map(call => (call as { sessionId: SessionId }).sessionId)) + .toEqual([requested, requested]) + }) +}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index b980a674fe..136709b20c 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -217,19 +217,33 @@ describe('paging', () => { }) describe('prompt and cancel errors', () => { - it('sends content through session.prompt with the mode passed through', async () => { + it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { const { api, session } = makeSession() - const result = await session.prompt([{ type: 'text', text: '要发的' }], 'queue') + // The blank → engaging edge fires before the RPC settles: the first-send + // flow reads the phase on the session area's first frame to keep the + // guidance hero from flashing back in. + expect(session.getSnapshot().composerPhase).toBe('blank') + const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue') + expect(session.getSnapshot().composerPhase).toBe('engaging') + const result = await inFlight expect(result.ok).toBe(true) + // Monotone: settlement alone does not step the phase anywhere. + expect(session.getSnapshot().composerPhase).toBe('engaging') expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }]) + // First content lands (running turn): engaging → active. + session.handleRunning(true) + expect(session.getSnapshot().composerPhase).toBe('active') }) - it('business failure lands in promptError with op=send', async () => { + it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') expect(result.ok).toBe(false) expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } }) + // Failed first prompt: composer + error strip is the retry surface — + // blank is unreachable once a send was initiated. + expect(session.getSnapshot().composerPhase).toBe('engaging') }) it('lands cancel failures in promptError with op=stop', async () => { diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 8c850426bd..a6834071d0 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -9,7 +9,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import { SessionsService, scopeOf } from '../src/client/sessions/service.ts' +import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' import { FakeApiClient, ok } from './fake-api.ts' const sid = (s: string): SessionId => s as SessionId @@ -36,14 +36,14 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}), })), }) as never) - await b.svc.manager.refreshList() + await b.svc.refresh() await Promise.resolve() // manager notifier flush } describe('list store projection', () => { it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => { const b = bench() - b.svc.manager.handleMuxEnvelope({ + b.svc.handleMuxEnvelope({ rpcId: 'title' as never, payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 }, }) @@ -61,7 +61,7 @@ describe('list store projection', () => { it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never }) + b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never }) await Promise.resolve() expect(b.svc.list.getSnapshot().ids).toContain('s2') }) @@ -77,7 +77,7 @@ describe('scope tree', () => { expect(scopeOf(scoped as Context)).toBe('s1') expect(scopeOf(b.ctx)).toBeUndefined() const binding = b.svc.binding(sid('s1')) - expect(binding?.session).toBe(b.svc.manager.get(sid('s1'))) + expect(binding?.session).toBe(b.svc.cell('s1')?.session) expect(b.svc.binding(sid('s1'))).toBe(binding) expect(binding?.ctx).toBe(scoped) }) @@ -187,9 +187,8 @@ describe('cell (render-layer session kit)', () => { const cell = b.svc.cell('s1') expect(cell).toBeDefined() expect(cell?.sessionId).toBe('s1') - // Bare-source form (store migration): the cell carries the Session - // observable itself; hook binding happens in the React machinery. - expect(cell?.session).toBe(b.svc.manager.get(sid('s1'))) + // The cell carries the observable; hook binding happens in React. + expect(cell?.session).toBe(b.svc.binding(sid('s1'))?.session) expect(b.svc.cell('s1')).toBe(cell) expect(b.svc.cell('ghost')).toBeUndefined() }) @@ -285,36 +284,45 @@ describe('ancestry', () => { }) describe('create', () => { - it('returns the new id on ok and throws a coded error on failure', async () => { + it('passes a preallocated id and preserves it on ordinary failure', async () => { const b = bench() b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') })) - await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh') + await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh') + expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }]) b.api.onCreate = () => Promise.resolve({ rpcId: 'e' as never, result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } }, } as never) - await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/) - }) -}) - -describe('createWorkspace', () => { - it('joins host.describe cwd with the name and creates there', async () => { - const b = bench() - b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 })) - b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') })) - await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws') - expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }]) + const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(SessionCreateError) + expect(failure).toMatchObject({ + requestedSessionId: 'candidate', publishedSessionId: undefined, + rpcError: { code: 'internal', message: '爆了' }, + }) }) - it('rejects empty names and path separators; surfaces describe failures', async () => { + it('surfaces the definitely published id after Workspace attachment fails', async () => { const b = bench() - await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/) - await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/) - b.api.onDescribe = () => Promise.resolve({ - rpcId: 'e' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } }, + b.api.onCreate = () => Promise.resolve({ + rpcId: 'attach' as never, + result: { + ok: false, + error: { + code: 'workspace-attach-failed', message: 'ledger unavailable', + details: { sessionId: sid('published'), workspaceId: 'ws' }, + }, + }, } as never) - await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/) + const failure = await b.svc.create({ + workspaceId: 'ws' as never, + sessionId: sid('published'), + }).catch((error: unknown) => error) + await Promise.resolve() + expect(failure).toMatchObject({ + publishedSessionId: 'published', requestedSessionId: 'published', + rpcError: { code: 'workspace-attach-failed' }, + }) + expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published' }) }) }) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 12f4f1f05f..069cc788d5 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -85,11 +85,18 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost { }) bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C) bench.ctx.reflect.provide('sessions', fakeSessions()) + bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) bench.erased.renderSlot('root', {}) if (host === undefined) throw new Error('renderer never received the host') return host } +/** Minimal independent Workspace list source for the renderer host seam. */ +function fakeWorkspaces() { + const state = { items: [], phase: 'ready' as const } + return { list: { getSnapshot: () => state, subscribe: () => () => undefined } } +} + /** Minimal sessions face for the host seam (list observable + cell). */ function fakeSessions() { const state = { ids: [], byId: {}, current: undefined as string | undefined } @@ -190,9 +197,18 @@ describe('renderer install seam', () => { bench.erased.install({ renderRoot }) bench.erased.register({ name: 'root' }, C) bench.ctx.reflect.provide('sessions', fakeSessions()) + bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) expect(bench.erased.renderSlot('root', {})).toBe('tree') expect(renderRoot).toHaveBeenCalledTimes(1) }) + + it('fails before rendering when the Workspace object layer is absent', async () => { + const bench = await boot() + bench.erased.install({ renderRoot: () => null }) + bench.erased.register({ name: 'root' }, C) + bench.ctx.reflect.provide('sessions', fakeSessions()) + expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/) + }) }) describe('host face', () => { @@ -220,6 +236,12 @@ describe('host face', () => { expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' }) expect(host.sessions.cell('ghost')).toBeUndefined() }) + + it('exposes the independent Workspace list source', async () => { + const bench = await boot() + const host = captureHost(bench) + expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' }) + }) }) describe('store instance axis', () => { @@ -315,6 +337,7 @@ describe('entry-unload cascade', () => { renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' }, }) bench.ctx.reflect.provide('sessions', fakeSessions()) + bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) // The declarer here is NOT the root occupant: root stays occupied by a // separate entry so disposing the declarer only kills its children. const disposeRoot = bench.erased.register({ name: 'root' }, C) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts new file mode 100644 index 0000000000..c2c2c62b86 --- /dev/null +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -0,0 +1,157 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import { SessionsService } from '../src/client/sessions/service.ts' +import { WorkspaceManager } from '../src/client/workspaces/manager.ts' +import { WorkspacesService } from '../src/client/workspaces/service.ts' +import { FakeApiClient, deferred, err, ok } from './fake-api.ts' + +const sid = (id: string): SessionId => id as SessionId +const wid = (id: string): WorkspaceId => id as WorkspaceId + +function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView { + return { + workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds, + createdAt, updatedAt: createdAt, + } +} + +describe('WorkspaceManager', () => { + it('owns, materializes, retries, supersedes, and discards Workspace objects with local intents', async () => { + const api = new FakeApiClient() + const manager = new WorkspaceManager(api) + manager.startIntent('first') + expect(manager.getSnapshot().intent).toEqual({ name: 'first', phase: 'ready' }) + + api.onWorkspaceCreate = () => Promise.resolve(err({ + code: 'workspace-name-conflict', message: 'taken', details: { name: 'first' }, + } as never)) + await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: false }) + expect(manager.getSnapshot().intent).toMatchObject({ name: 'first', phase: 'ready' }) + expect(typeof manager.getSnapshot().intent?.error).toBe('string') + + const gate = deferred>>() + api.onWorkspaceCreate = () => gate.promise + const stale = manager.materializeIntent() + expect(manager.getSnapshot().intent?.phase).toBe('creating') + manager.startIntent('replacement') + gate.resolve(ok({ workspace: workspace('first'), created: true })) + await stale + expect(manager.getSnapshot().intent).toEqual({ name: 'replacement', phase: 'ready' }) + + api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('replacement'), created: true })) + await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: true }) + expect(manager.getSnapshot().intent).toBeUndefined() + await expect(manager.materializeIntent()).resolves.toBeUndefined() + manager.discardIntent() + manager.startIntent('discarded') + manager.discardIntent() + expect(manager.getSnapshot().intent).toBeUndefined() + }) + + it('replays changed frames over hydration and keeps established order on refresh', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const manager = new WorkspaceManager(api) + const hydration = manager.refresh() + manager.handleHostEnvelope({ + rpcId: 'changed' as never, + payload: { type: 'host/workspace-changed', workspace: workspace('new') }, + }) + gate.resolve(ok({ items: [workspace('old')] as never[] })) + await hydration + expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old']) + + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('old'), workspace('new')] as never[], + })) + await manager.refresh() + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old']) + }) + + it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const manager = new WorkspaceManager(api) + const first = manager.refresh() + const second = manager.refresh() + expect(manager.getSnapshot().state).toBe('loading') + gate.resolve(ok({ items: [] })) + await Promise.all([first, second]) + expect(api.callsOf('workspace.list')).toHaveLength(1) + + api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) + await manager.refresh() + expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } }) + api.onWorkspaceList = () => Promise.reject(new Error('wire down')) + await manager.refresh() + expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } }) + }) + + it('creates by name/path, prepends a new row, and folds failures', async () => { + const api = new FakeApiClient() + const manager = new WorkspaceManager(api) + api.onWorkspaceCreate = payload => Promise.resolve(ok({ + workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'), + created: true, + payload, + } as never)) + await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }]) + expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created') + + api.onWorkspaceCreate = () => Promise.reject(new Error('create transport')) + await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({ + ok: false, error: { code: 'internal', message: 'create transport' }, + }) + }) +}) + +describe('WorkspacesService', () => { + it('feeds SessionManager readiness and recent-Workspace targeting without changing Host order', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [ + workspace('stable-first', [], '2026-01-03T00:00:00.000Z'), + workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'), + ] as never[], + })) + await workspaces.refresh() + await Promise.resolve() + expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined }) + + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false }] as never[], + })) + await sessions.refresh() + await Promise.resolve() + await Promise.resolve() + expect(workspaces.list.getSnapshot()).toMatchObject({ + baselinesReady: true, + recentWorkspaceId: 'active', + }) + expect(sessions.list.getSnapshot().intent).toMatchObject({ + target: { kind: 'workspace', workspaceId: 'active' }, + }) + expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active']) + }) + + it('returns created Workspaces and preserves Host business errors', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }]) + api.onWorkspaceCreate = () => Promise.resolve(err({ + code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' }, + })) + await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) + }) +}) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ebd7474298..0711adcccb 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,13 +2,15 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. + The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain). +Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 372eb36c80..e14f110b7c 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,14 +1,4 @@ -/** - * Client plugin body: register the conversation/details slot occupants and - * the no-session empty state, contribute the chat entry into the - * 'conversation.view' ring that the conversation registration declares, then - * mount the conversation service (class plugin) and the bash toolview sample. - * Assembly only — components receive everything through props: the framework - * standard kit and store faces arrive automatically from the declarations - * below; the inject factories contribute the plain-data-and-callbacks - * business face (design §5). Tool rows are ordinary keyed-slot registrations - * into 'conversation.chat.toolview' — no dedicated registry exists. - */ +/** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -25,8 +15,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { EmptyState } from './skeleton/EmptyState.tsx' -/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ -export const inject = ['slots', 'layout', 'sessions'] +/** Services required by the conversation plugin. */ +export const inject = ['slots', 'layout', 'sessions', 'workspaces'] /** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService { @@ -37,24 +27,18 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat return conversation } -/** - * Client plugin body. - * @param ctx - client root context. +/** Mounts the conversation plugin. + * @param ctx - Client root context. */ export function apply(ctx: Context): void { const sessions = ctx.sessions + const workspaces = ctx.workspaces const layout = ctx.layout const slots = ctx.slots - // Shared store handle, constructed here so its identity lives and dies with - // this fiber (a module-level handle would be a de-facto singleton). The - // conversation, chat-view, and details registrations all declare it; same - // scope key = same instance, so chat-view selection writes and details - // reads meet in one store. + // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() - // Tab projection over the view ring's ledger (list entries carry id/order/ - // label as registration options; the ledger keeps them order-sorted). const viewTabs = (): ViewTab[] => { const tabs: ViewTab[] = [] for (const entry of slots.entries('conversation.view')) { @@ -103,7 +87,9 @@ export function apply(ctx: Context): void { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, - open: (target: SessionId) => { sessions.open(target) }, + open: (sessionId) => { sessions.open(sessionId) }, + updateSessionPrompt: (text) => { scoped.updatePendingPrompt(text) }, + retrySessionPrompt: () => { scoped.retryPendingPrompt() }, } }, }, ConversationRoot) @@ -120,13 +106,16 @@ export function apply(ctx: Context): void { label: 'Chat', children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } }, store: chatStore, - inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => ({ - openDetails: (target) => { - actions.select(target) - layout.openDetails() - }, - loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() }, - }), + inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { + const scoped = scopedConversation(sessions, sessionId) + return { + openDetails: (target) => { + actions.select(target) + layout.openDetails() + }, + loadOlder: () => { void scoped.loadOlder() }, + } + }, }, ChatView) // Class-plugin mount (packages/AGENTS.md service form): the service @@ -150,20 +139,11 @@ export function apply(ctx: Context): void { slots.register({ name: 'conversation.empty', + children: { 'conversation.empty.workspace': { kind: 'single', scope: 'root' } }, inject: (): EmptyStateInjected => ({ - // ctx.get, not ctx.conversation: the service mounts on this plugin's - // own child fiber, so it is not in the inject topology the property - // proxy enforces; get reads the global store and stays loud on a torn - // boot through the optional-chain throw below. - startSession: (opts) => { - const conversation = ctx.get('conversation') - if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') - return conversation.startSession(opts) - }, - createWorkspaceSession: async (name) => { - const id = await sessions.createWorkspace(name) - sessions.open(id) - }, + startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) }, + updateSessionPrompt: (text) => { sessions.updateIntent(text) }, + sendSession: () => { workspaces.sendSession() }, }), }, EmptyState) } diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index d7211f2f91..50dead9529 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -1,9 +1,4 @@ -// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284 -// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow -// (part of the chat view body — the chrome attachment mechanism retired with -// the view ring). Duration has no data source in P-I (ledger). Subscribes to -// `nodes` only: chunk batches never swap that reference, so the row renders -// zero times during streaming (the RFC performance model's acceptance row). +// Settled-node identity prevents stream-delta updates from rerendering this row. import { memo, useMemo } from 'react' import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index ffbc13ff59..095b57a5f8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,17 +1,7 @@ -/** - * Slot-ring contract for the conversation package: the 'conversation.view' - * slot this package declares (the view ring — one list entry per conversation - * view tab), the chat view's per-tool row hole ('conversation.chat.toolview', - * keyed on the wire tool name), and the composed props shapes its registrants - * mount into the layout-owned slots (conversation / details / - * conversation.empty) plus its own slots. Terminal slot design (§3): full - * component props are the automatic shares — PropsRuntime (framework - * standard kit) & PropsRenderSlots (declared children) & PropsStore - * (declared store's read/write faces) & the injected business face declared - * here. - */ +/** Conversation slot declarations and their composed component props. */ +import type { RefObject } from 'react' import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -41,6 +31,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * zero owner changes. */ 'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps } + /** Shared Workspace picker hole used by the page-local Session Intent hero. */ + 'conversation.empty.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps } } } @@ -93,15 +85,9 @@ export type ConvViewProps = PropsRuntime<'conversation.view'> /** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */ export type ChatStore = ReturnType -/** - * Injected share of the conversation slot: plain data and callbacks only - * (design §5 — hooks are framework-made). The store lines that used to ride - * here live in the declared {@link ChatStore}; ancestry derives from the - * standard useSessions hook in-component; views render through the declared - * 'conversation.view' child slot, with this face projecting the tab strip. - */ +/** Business callbacks injected into the conversation slot. */ export interface ConversationInjected { - /** View tab read face (uSES triple over the 'conversation.view' slot ledger). */ + /** Views projected from the `conversation.view` slot ledger. */ views: { list(): readonly ViewTab[] subscribe(fn: () => void): () => void @@ -111,8 +97,12 @@ export interface ConversationInjected { send(text: string, mode: 'queue' | 'steer'): void /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ stop(): void - /** Navigate to another session (breadcrumb ancestors). */ - open(id: SessionId): void + /** Select a real Session through the runtime navigation owner. */ + open(sessionId: SessionId): void + /** Update the scoped Session's retained prompt. */ + updateSessionPrompt(text: string): void + /** Retry the scoped Session's retained prompt. */ + retrySessionPrompt(): void } /** @@ -123,7 +113,6 @@ export interface ConversationInjected { * with zero owner changes. */ export interface ComposerChainProps { - /** The session's live pending waits, in arrival order (snapshot reference). */ interactions: readonly PendingInteraction[] } @@ -139,7 +128,6 @@ export type ConversationSlotProps = export interface ChatViewInjected { /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ openDetails(target: SelectionTarget): void - /** Pull one older history page. */ loadOlder(): void } @@ -160,16 +148,24 @@ export interface DetailsInjected { /** Full details-slot component props: selection arrives through the shared store, call material through useSession. */ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & DetailsInjected -/** Injected share of the no-session empty-state slot. */ -export interface EmptyStateInjected { - /** The create → navigate → first-send chain, in one service call. */ - startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise - /** - * Create a workspace folder under the host cwd, mint a session there, and - * open it (Create-new modal success path). - */ - createWorkspaceSession(name: string): Promise +/** Owner share common to the empty hero's Workspace picker. */ +export interface EmptyWorkspaceOwnerProps { + open: boolean + anchorRef?: RefObject + onPick(workspaceId: WorkspaceId): void + onClose(): void } -/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */ -export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected +/** Runtime-owned actions injected into the empty-state occupant. */ +export interface EmptyStateInjected { + /** Replace the current Session intent, optionally preserving a prompt while retargeting. */ + startSession(workspaceId?: WorkspaceId, prompt?: string): void + /** Update the current Session intent's controlled prompt. */ + updateSessionPrompt(text: string): void + /** Materialize and send the current Session intent. */ + sendSession(): void +} + +/** Full empty-state component props: runtime projections, picker child slot, and injected actions. */ +export type EmptyStateSlotProps = + PropsRuntime<'conversation.empty'> & PropsRenderSlots<'conversation.empty.workspace'> & EmptyStateInjected diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index da573f007a..9ef9515f19 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -1,14 +1,4 @@ -/** - * Shared conversation contract primitives: the view tab projection (slot - * entries in 'conversation.view' surface as tabs), the chat store state - * shared through the declared store, and the selection primitives every - * domain consumes. Shared face between the skeleton domain (tab strip + - * view outlet) and the chat domain; domain implementation files import this, - * never each other. The view ring itself IS the 'conversation.view' slot - * (contract in slots.ts) — the package-local view registry is retired, and - * so is the hand-threaded translate channel (framework-level per-slot i18n - * injection is the planned replacement). - */ +/** Shared conversation view, selection, and store-state contracts. */ /** Tool call identity as carried on the wire (branded upstream in connection). */ export type CallId = string @@ -23,11 +13,8 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C export interface ViewTab { id: string; label: string } /** - * Chat store state (slot terminal design §4): the per-session store shared by - * the conversation, chat-view, and details registrations. `createChatStore` - * implements this shape. `view` may carry a stale persisted id after a view - * plugin unloads — the slot ledger is the runtime validator (unknown ids fall - * back to the first registered view). + * Per-session state shared by conversation, chat-view, and details slots. + * Unknown persisted view ids fall back to the first registered view. */ export interface ChatStoreState { /** Details-linkage channel (conversation writes, details reads). */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 8cfebac81c..a48dfdad34 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,12 +1,7 @@ /** - * Conversation domain plugin, browser half: skeleton (header/tabs/composer), - * the 'conversation.view' slot ring (chat entry here; other plugins - * contribute view tabs through ctx.slots), the chat view's keyed - * 'conversation.chat.toolview' row hole, scope-addressed ConversationService, - * minimal details panel. Contract: api-contracts v3 section 7. Thin shell: - * type surfaces live in contract/, assembly in apply.ts; the implementation - * domains (skeleton/chat) never import each other — contract/ is their only - * shared face. + * Browser conversation plugin. `contract/` is the shared type boundary + * between the independently implemented skeleton and chat domains; `apply.ts` + * owns their slot assembly. */ import type { ConversationService } from './service.ts' @@ -20,7 +15,7 @@ export type { ToolCallBlock } from './contract/tool-call-model.ts' export type { ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, - EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps, + EmptyStateInjected, EmptyStateSlotProps, EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 0c6b9632bb..5ea5ea96ee 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -1,17 +1,11 @@ /** - * ConversationService implementation: scope-addressed send/cancel and the - * empty-state startSession chain. Contract: api-contracts v3 section 7. - * Selection/draft state moved to the declared chat store (slot terminal - * design §4); the view registry moved to the 'conversation.view' slot (slot - * ledger owns registration, ordering, and disposal) — what remains is the - * send/stop orchestration face. + * Scope-addressed conversation send, cancel, history, and retained-prompt orchestration. * * Scope addressing rides the cordis Service tracker: property access through * `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods - * read the session tag with scopeOf (same mechanism as the host tool - * registry). Mutable state lives in plain objects reached by one property - * read — field assignment through the tracker's shadow proxy is off-limits, - * as are `#` hard-private fields. + * read the session tag with `scopeOf`. Mutable state must remain reachable + * through one property read; assignment through the tracker proxy and `#` + * private fields bypass that rebinding. */ import { Service } from 'cordis' import type { Context } from 'cordis' @@ -50,37 +44,30 @@ export class ConversationService extends Service { if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`) } + /** Pull one older history page for the scoped Session. */ + async loadOlder(): Promise { + await this.scopedSession('loadOlder').loadOlder() + } + /** - * Empty-state first-send chain (root-context method; does not read scope): - * create the session, navigate to it, then send through the new scope. - * The create → open ordering is safe: the manager merges the new summary - * synchronously before create() resolves, so the list store is projected by - * the time open() validates against it (manager notification batching is - * microtask-based; SessionsService projects on the same flush that create - * awaited through the RPC round trip). - * @param opts - project directory, prompt text, and send mode. + * Update the scoped Session's retained pending prompt. + * @param text - exact controlled-input value to retain. */ - async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise { - const sessions = this.requireSessions() - const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd }) - // The manager notifier flushes per microtask; one await guarantees the - // list-store projection landed before sessions.open validates against it. - await Promise.resolve() - sessions.open(id) - const scoped = sessions.scope(id) - if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`) - // ctx.get, not scoped.conversation: property access walks the fiber - // topology (a scope fiber never injects services), while get reads the - // global store and still binds this service to the scoped ctx. - const scopedConversation = scoped.get('conversation') - if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope') - await scopedConversation.send(opts.text, opts.mode) + updatePendingPrompt(text: string): void { + this.scopedSession('updatePendingPrompt').updatePendingPrompt(text) + } + + /** Retry the scoped Session's retained pending prompt. */ + retryPendingPrompt(): void { + this.scopedSession('retryPendingPrompt').retryPendingPrompt() } /** Resolve the caller scope's Session or throw on root contexts. */ private scopedSession(op: string): Session { const id = this.scopeId(op) - return this.requireSessions().manager.get(id) + const binding = this.requireSessions().binding(id) + if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`) + return binding.session } /** Read the caller's session scope tag via the sessions service; root contexts fail loud. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 59346a557b..a87f6e4aa9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d import type { ConversationSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' +import { EmptyHero, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import css from './ConversationRoot.module.css' /** Full props = the automatic shares & injected share — composed by reference @@ -37,8 +38,8 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session } export function ConversationRoot({ - sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain, - views, send, stop, open, + sessionId, useSession, useSessions, useWorkspaces, useStore, actions, renderSlot, renderSlotChain, + views, send, stop, open, updateSessionPrompt, retrySessionPrompt, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -48,16 +49,60 @@ export function ConversationRoot({ const active = tabs.find(v => v.id === activeId) ?? tabs[0] const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) - const draft = useStore(s => s.draft) - const running = useSession(s => s.running) + const pendingPrompt = useSession(s => s.pendingPrompt ?? undefined) + const storedDraft = useStore(s => s.draft) + const draft = pendingPrompt?.text ?? storedDraft + const sessionRunning = useSession(s => s.running) + const running = sessionRunning || pendingPrompt?.phase === 'sending' const removed = useSession(s => s.removed) const promptError = useSession(s => s.promptError) const turns = useSession(s => countTurns(s)) const pending = useSession(s => s.pending) + const openState = useSession(s => s.openState) + const composerPhase = useSession(s => s.composerPhase) + const cwd = useSessions(s => s.byId[sessionId]?.cwd) + const workspaceTitle = useWorkspaces(state => + state.items.find(workspace => workspace.sessionIds.includes(sessionId))?.title) + const error: InputBarError | null = pendingPrompt?.error !== undefined + ? { + op: pendingPrompt.retry === 'connect' ? 'session' : 'send', + message: pendingPrompt.retry === 'connect' + ? `Workspace attach failed: ${pendingPrompt.error}` + : `Message send failed: ${pendingPrompt.error}`, + } + : promptError === null + ? null + : { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` } + const status = pendingPrompt?.phase === 'sending' + ? pendingPrompt.retry === 'connect' ? 'Attaching session to workspace…' : 'Sending message…' + : undefined + const setDraft = (text: string): void => { + if (pendingPrompt === undefined) actions.setDraft(text) + else updateSessionPrompt(text) + } + const submit = (mode: 'queue' | 'steer'): void => { + if (pendingPrompt === undefined) send(draft, mode) + else retrySessionPrompt() + } - const error: InputBarError | null = promptError === null - ? null - : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } + // Blank-session guidance: phase-derived (the runtime snapshot owns the + // predicate — see ComposerPhase). Only `blank` renders the hero; `engaging` + // and `active` fall through to the conversation view, so an in-flight + // first send never bounces back here. Gated on the OPEN window: phase has + // no jurisdiction over loading/error frames (ChatView renders those). + if (openState === 'open' && composerPhase === 'blank') { + return ( + } + draft={draft} + disabled={removed || pendingPrompt?.phase === 'sending'} + error={error} + {...(status === undefined ? {} : { status })} + onDraftChange={setDraft} + onSend={submit} + /> + ) + } // The default composer doubles as the chain's all-decline fallback: a // pending wait with no registered takeover must still leave the input usable. @@ -67,9 +112,10 @@ export function ConversationRoot({ running={running} disabled={removed} error={error} + {...(status === undefined ? {} : { status })} variant="composer" - onDraftChange={actions.setDraft} - onSend={(mode) => { send(draft, mode) }} + onDraftChange={setDraft} + onSend={submit} onStop={stop} /> ) @@ -78,7 +124,7 @@ export function ConversationRoot({
-