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-23-unified-session-query-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml index 2a27e6432f..7b83a5dc52 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.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-23-unified-session-query-service.md: 0a466e1c36ff1796c858666b0eb36bbd0f480bb0 -2026-07-23-unified-session-query-service.zh.md: 448122b8e6951058b9f633cd56112b0391e1912e +2026-07-23-unified-session-query-service.md: 676a42017ca42f9e649f6529f84787e7162faac0 +2026-07-23-unified-session-query-service.zh.md: d4449a415840d61cbb10f88def1062a13e556749 diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md index 0a466e1c36..676a42017c 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md +++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md @@ -16,6 +16,8 @@ The interface package already owns the shared record, filter, trace, search-requ `SessionQuerySqlite` extends that service and is the sole concrete backend. One mounted instance therefore exposes every operation through `ctx.sessionQuery`; its inherited exact operations use the shared corpus implementation, while its SQLite-owned lifecycle observes sources, reconciles the derived FTS index, ranks matches, and owns cursor generations. The interface package has no standalone concrete plugin, search-provider registry, or second context key. +SQLite reconciliation is one quiescent serialized state machine. It passes the caller's exact abort signal into durable snapshot listing and inspection, awaits each started backend operation itself, and checks cancellation after every await and before starting the next source or index operation. Cancellation therefore cannot release the serializer while an ignored or cooperative backend call is still cleaning up, and it cannot start a subsequent listing, inspection, reconciliation, or query after the signal is observed. + Backend configuration includes the inherited `readWindowMax` setting alongside its own index path, journal mode, page limits, and snippet limit. First-party apps that need session queries mount the SQLite backend and place its disposable index beside their configured persistence root. This service topology supersedes the separate-key portion of the [exact query decision](../feature/2026-07-10-session-query-service.md) and [SQLite search decision](../feature/2026-07-10-sqlite-session-query-provider.md); their corpus, query, tokenizer, reconciliation, and safety decisions remain in force. @@ -32,4 +34,6 @@ Consumers inject one service and can combine exact and full-text operations with The unified object deliberately retains two internal observation strategies: exact operations read authoritative live/persisted sources per call, while full-text operations reconcile a disposable index. Sharing the context key does not make the derived index authoritative or couple exact-read availability to an FTS query. +Queued cancellation remains prompt. Cancellation during active asynchronous source observation waits for that started operation to settle, which makes rejection a quiescence boundary and preserves single-file execution for a following search. Synchronous SQLite statements remain non-preemptible and are bracketed by signal checks. + Unit coverage pins inherited and abstract behavior on one key, SQLite coverage exercises both operation families on the concrete backend, and the real Loader path verifies that one exported plugin registers the combined service. diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md index 448122b8e6..d4449a4158 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md @@ -16,6 +16,8 @@ Status: implemented `SessionQuerySqlite` 扩展该服务,并且是唯一的具体后端。因此,一个挂载实例便可通过 `ctx.sessionQuery` 暴露全部操作;其继承的精确操作使用共享的语料库实现,而由 SQLite 管理的生命周期负责观察数据源、对齐派生 FTS 索引、对匹配项排序并管理游标代际。接口包不提供独立的具体插件、搜索提供方注册表或第二个上下文键。 +SQLite 的对齐过程是一个具备静止性保证的串行状态机。它将调用方的原始中止信号传给持久化快照列表与检查操作,直接等待每个已经启动的后端操作,并在每次等待后以及启动下一个数据源或索引操作前检查是否已取消。因此,即使后端忽略取消或正在配合清理,串行器也不会提前释放;观察到中止信号后,也不会再启动后续的列表、检查、对齐或查询操作。 + 后端配置除了自身的索引路径、日志模式、分页限制与文本片段长度上限外,还包含继承的 `readWindowMax` 设置。需要会话查询的第一方应用挂载 SQLite 后端,并将其可丢弃索引放在已配置的持久化根目录旁。 这一服务拓扑取代了[精确查询决策](../feature/2026-07-10-session-query-service.md)和 [SQLite 搜索决策](../feature/2026-07-10-sqlite-session-query-provider.md)中关于分离上下文键的部分;其中关于语料库、查询、分词器、对齐与安全性的决策仍然有效。 @@ -32,4 +34,6 @@ Status: implemented 统一后的对象有意保留两种内部观察策略:精确操作在每次调用时读取权威的实时源或持久化源,全文操作则使可丢弃索引与数据源对齐。共用上下文键不会让派生索引成为权威来源,也不会使精确读取的可用性依赖 FTS 查询。 +排队阶段的取消仍会及时生效。在异步数据源观察已经开始后取消时,调用方会等待该操作完成清理后才收到拒绝;因此拒绝本身构成静止边界,并保证后续搜索仍按单一串行流程执行。同步 SQLite 语句无法在执行中被抢占,服务会在其前后检查中止信号。 + 单元测试在同一个键上同时固定继承实现与抽象方法的契约,SQLite 测试在具体后端上覆盖两类操作,真实 Loader 路径则验证单个导出的插件能够注册组合后的服务。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml new file mode 100644 index 0000000000..7bdc4d2825 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: c1124f67a2c5d9fbba1e04c896a1021c370befc9 +2026-07-24-dsh-commander-argument-adapter.zh.md: be96c354a7dea53446f3c2e35f0e4967265596f5 diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md new file mode 100644 index 0000000000..c1124f67a2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -0,0 +1,53 @@ +# Agent Note: Parse `dsh` argv through one Commander adapter + +Status: implemented + +English | [中文](2026-07-24-dsh-commander-argument-adapter.zh.md) + +## Problem + +The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that did not compose and gave no `--help`/`--version`. `bin.ts` dispatched by raw inspection — `argv[0] === 'web'`, then `argv.includes('-p') || argv.includes('--prompt')`, else TUI — which is positional-blind: a prompt flag or a config path in the wrong position could misroute the mode, and `argv.includes('-p')` could not tell a real flag from an incidental token. `headless.ts` and `web.ts` each ran their own `node:util` `parseArgs` with inline host/port validation, and `dsh-app-boot` carried `parseResumeArg`, a ~30-line bespoke scanner reimplementing flag/`=`-form/value/repeat handling for `--resume`. Usage was a single hardcoded `usage: dsh -p "task"` line; there was no version flag and no rendered help. + +## Decision + +Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host?, port?, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`. + +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev, workspaceRoot)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config `, `-p/--prompt `, `--resume ` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume). Commander parses the default-surface options on either side of the `web` token into `program.opts()`; since `web` shares none of them, the `web` action rejects a leaked `--config`/`-p`/`--resume` (`dsh web -p x`, `dsh --config c.yml web`) rather than silently serving and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the adapter assigns no default and does no validation, only `Number`-coercing the port string (the schema wants a number). The `dsh-host-webserver` schemastery `Config` (`host` a `127.0.0.1`/`0.0.0.0` literal union, `port` a natural ≤ 65535) is the single source of both the default (the shipped `apps/cli/cordis.yml` `webserver` row stands when a flag is absent) and validity — `AppCLIEntry` patches an explicit flag straight into that row, so a bad host/port fails loud at the schema on boot, not at parse. `--dev` mounts the client HMR driver and bundle watch, and `--workspace-root ` is a plain pass-through to `AppCLIEntry` (the parent directory for name-created workspaces). A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`. + +`dsh` takes no positional argument. `--config ` names an alternate cordis tree to boot instead of the shipped default; it exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`. + +CLI parsing lives entirely in `apps/cli`. `dsh-app-boot` holds the boot/env/config/personal-overlay helpers and no argv scanner. + +## Session resume through the boot context + +`dsh --resume ` is the one way to resume a persisted session, with no environment variable. `runTui` provides the parsed id on the boot context through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a `dsh-app-boot` export, value `'resumeSessionId'`) — and the shipped tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv from the parsed values as `dsh --resume= [--config ]`. + +## One terminal front door: `dsh` + +`dsh` is the only terminal entry point; the `dsh-tui-demo` package ships the TUI app bundle plugin the shipped config mounts, and no bin of its own. `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes launch through `apps/cli/src/bin.ts` with `--config `. `dsh`'s TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) is pinned by `apps/cli/tests/built-bin.e2e.ts`, which runs the built `lib/bin.js` under plain Node with piped stdio (`apps/cli/tests` is in the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their own bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. + +## Package topology + +The argument surface stays inside `apps/cli`, the assembly tier, not a `packages/*` library: it is this one app's routing, not a reusable seam. `dsh-app-boot` shrinks to boot glue with no CLI-parsing responsibility. `commander@^15` is added to `apps/cli/package.json`, matching the SDK bins' pin. + +## Alternatives considered + +**Keep `node:util` `parseArgs` and only unify the dispatch** — rejected: `parseArgs` has no subcommand model, no rendered help, and no version flag, so `web` routing and `--help`/`--version` would stay hand-rolled. The repo already chose Commander for its other CLIs; a second parser idiom for `dsh` alone is the fragmentation this change removes. + +**Keep `parseResumeArg` as a shared helper and feed it Commander's residual args** — rejected: the whole point is to retire the bespoke scanner. Commander parses `--resume` (space and `=` forms, missing-value, position-independence) natively; keeping a parallel hand-written path for the one flag would preserve the duplication the change exists to end. + +**A bare `dsh ` positional for the alternate tree** — rejected: a root positional and a real `web` subcommand cannot coexist in one Commander program (the subcommand claims the first positional). A positional would force `web` into a reserved-first-token dispatch to a separate parser and a hand-maintained `web` line in `--help`. Only the demo/test sites ever need to name an alternate tree, so a `--config` flag serves them while leaving the default surface positional-free — `web` is then a normal subcommand in one program with native `--help`. + +**Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern. + +**Keep `RESUME_SESSION_ID` as the resume bridge** — rejected: with `--resume` parsed into a value the bin already holds, threading it through an environment variable the config re-reads is indirection with no benefit, and it left the demo bin a second, env-only resume path. Providing the id on the boot context is the same channel `boot`'s `prepare` hook already uses for `tuiResumeHost`. + +**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh --config ` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point. + +## Testing + +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev` and the host/port pass-through), the exit-code behavior for the adapter's fail-loud checks (empty resume/prompt, `--prompt` mixed with a config/`--resume`, unknown option, stray positional), and `--help`/`--version`, captured through a `process.exit` spy. Host/port validity is the webserver schema's job, exercised on boot by the web smoke, not the adapter spec. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. + +## Consequences + +`dsh` has rendered `--help`/`--version` and consistent fail-loud parse errors, and mode routing does not depend on flag position. Argv parsing lives in one place with one parser idiom shared with the SDK bins, at the cost of a `commander` dependency on `apps/cli` and Commander's parse semantics (its error strings, its `exitOverride` contract) sitting on the CLI's front door. `dsh-app-boot` owns no CLI-parsing surface; a consumer needing `--resume`-style parsing composes Commander. Session resume rides the boot context rather than an environment variable, and `dsh` is the single terminal front door — the `dsh-tui-demo` package is a plugin bundle a config mounts. diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md new file mode 100644 index 0000000000..be96c354a7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 通过单个 Commander 适配器解析 `dsh` 的 argv + +Status: implemented + +[English](2026-07-24-dsh-commander-argument-adapter.md) | 中文 + +## 问题 + +`dsh` 的 CLI(命令行界面)入口(`apps/cli`)以三种手写方式解析 argv,这些方式无法组合,也不提供 `--help`/`--version`。`bin.ts` 通过原始检查进行分发:先判断 `argv[0] === 'web'`,再判断 `argv.includes('-p') || argv.includes('--prompt')`,否则走 TUI。这种方式对位置不敏感:位置错误的 prompt 标志或配置路径可能把模式路由错,而 `argv.includes('-p')` 无法区分真正的标志和偶然出现的 token。`headless.ts` 和 `web.ts` 各自运行自己的 `node:util` `parseArgs`,并内联校验 host/port,而 `dsh-app-boot` 携带 `parseResumeArg`——一个约 30 行的定制扫描器,为 `--resume` 重新实现了标志、`=` 形式、取值和重复的处理。用法说明只有一行硬编码的 `usage: dsh -p "task"`;既没有版本标志,也没有渲染出的帮助信息。 + +## 决策 + +argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host?, port?, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。 + +`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev, workspaceRoot)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config `、`-p/--prompt `、`--resume `——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复)。Commander 会将 `web` token 前后的默认接口选项都解析进 `program.opts()`;由于 `web` 不与默认接口共用任何选项,`web` 子命令的处理函数会拒绝误入的 `--config`/`-p`/`--resume`(`dsh web -p x`、`dsh --config c.yml web`),而不是静默启动服务并丢弃这些选项。`dsh web` 的 `--host`/`--port` 是未经校验、直接透传的覆盖值:适配器既不设置默认值,也不执行校验,只使用 `Number` 将端口字符串转换为数字(schema 要求该值为数字)。`dsh-host-webserver` 的 schemastery `Config`(`host` 是 `127.0.0.1`/`0.0.0.0` 字面量联合类型,`port` 是不大于 65535 的自然数)是默认值与有效性的唯一真源:未提供标志时,随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项保持原值;`AppCLIEntry` 将显式标志的值直接写入该配置项,因此无效的 host/port 会在启动时触发 schema 校验并显式失败,而不是在参数解析阶段失败。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视,`--workspace-root ` 则是直接透传给 `AppCLIEntry` 的选项(按名称创建 workspace 时使用的父目录)。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。 + +`dsh` 不接受位置参数。`--config ` 指定一份替代 Cordis 配置树,系统启动该配置树而不是随产品提供的默认配置树;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。 + +CLI 解析完全位于 `apps/cli` 中。`dsh-app-boot` 提供启动、环境变量、配置和个人覆盖辅助函数,不包含 argv 扫描器。 + +## 通过启动上下文恢复会话 + +`dsh --resume ` 是恢复持久化会话的唯一方式,无需环境变量。`runTui` 通过 `boot` 的 `prepare(ctx)` 钩子,在启动上下文中提供已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的一项导出,值为 `'resumeSessionId'`);随产品提供的 tui-agent/cordis 配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)根据已解析的值将重新执行时的 argv 构造成 `dsh --resume= [--config ]`。 + +## 唯一的终端入口:`dsh` + +`dsh` 是唯一的终端入口;`dsh-tui-demo` 包(package)提供 TUI 应用组合插件,随产品提供的配置会挂载该插件,而该包不提供自己的 bin。`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的无密钥 PTY 冒烟测试都通过 `apps/cli/src/bin.ts` 启动,并传入 `--config `。`dsh` 的 TTY 守卫会在启动前拒绝标准输入输出接入管道的调用,并提示自动化场景使用 `dsh -p`;`apps/cli/tests/built-bin.e2e.ts` 锁定了这一行为:该测试将标准输入输出接入管道,并通过普通 Node 运行构建后的 `lib/bin.js`(e2e Vitest 的 include 包含 `apps/cli/tests`)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 + +## 包拓扑 + +参数解析留在 `apps/cli`(组装层)内,而不是 `packages/*` 库中:它是这一个应用自身的路由,而非可复用的 seam。`dsh-app-boot` 收缩为纯粹的 boot 胶水代码,不再承担 CLI 解析职责。`commander@^15` 被加入 `apps/cli/package.json`,与 SDK bin 锁定的版本一致。 + +## 考虑过的替代方案 + +**保留 `node:util` `parseArgs`,只统一分发。** 已否决:`parseArgs` 没有子命令模型、没有渲染出的帮助、也没有版本标志,因此 `web` 路由和 `--help`/`--version` 仍将保持手写。本仓库其他 CLI 已经选择了 Commander;单独为 `dsh` 引入第二套解析器方式,正是这次变更要消除的碎片化。 + +**保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。 + +**使用裸 `dsh ` 位置参数指定替代配置树。** 已否决:根级位置参数与真正的 `web` 子命令无法在同一个 Commander 程序中共存(子命令会占用第一个位置参数)。位置参数会迫使系统把位于首位的 `web` 作为保留 token 分发给另一个解析器,并手工维护一行 `web` 文本,供 `--help` 显示。只有演示和测试调用点需要指定替代配置树,因此 `--config` 标志既能满足这些调用点,又能让默认接口不包含位置参数;这样,`web` 就能在单个程序中成为普通子命令,并由原生 `--help` 展示。 + +**把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。 + +**保留 `RESUME_SESSION_ID` 作为恢复通道**:不予采纳。`--resume` 已被解析成 bin 当前持有的值;若再通过环境变量传递并由配置重新读取,只会引入无益的间接层,还会使演示 bin 保留第二条仅依赖环境变量的恢复路径。在启动上下文中提供 id,与 `boot` 的 `prepare` 钩子为 `tuiResumeHost` 提供值所采用的是同一通道。 + +**保留 `dsh-tui-demo` bin**:不予采纳。它与 `dsh --config ` 的功能完全重复;保留它还会迫使演示专用的 `RESUME_SESSION_ID` 回退路径继续存在。配置实际挂载的是该包的插件;冗余的只有作为终端入口的 bin,而 `dsh` 是唯一的终端入口。 + +## 测试 + +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev` 和 host/port 透传),并通过 `process.exit` spy 捕获适配器的显式报错检查(恢复 id 或提示词为空、`--prompt` 与配置或 `--resume` 混用、未知选项、多余的位置参数)以及 `--help`/`--version` 的退出码。host/port 的有效性由 webserver schema 负责,并由 web 冒烟测试在启动时验证,不属于适配器测试的覆盖范围。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组通过 `--config` 启动示例树,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 + +## 影响 + +`dsh` 会渲染 `--help`/`--version`,并以一致方式显式报告解析错误;模式路由不依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 依赖 `commander`,且 Commander 的解析语义(错误字符串和 `exitOverride` 契约)成为 CLI 入口的一部分。`dsh-app-boot` 不提供任何 CLI 解析接口;需要 `--resume` 式解析的消费方通过组合 Commander 来实现。会话恢复通过启动上下文完成,而不使用环境变量;`dsh` 是唯一的终端入口;`dsh-tui-demo` 包是由配置挂载的插件组合包。 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/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml index da1ff70e09..d4b48d4f41 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.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-10-sqlite-session-query-provider.md: be8795b609b52eeb03268c4986b52004eef0dba9 +2026-07-10-sqlite-session-query-provider.md: 98618a7eb572ce59c5fa5984675c9dc57b3f4289 2026-07-10-sqlite-session-query-provider.zh.md: bb3650da907cf86a853f748fa0ee40d5c2168709 diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md index be8795b609..98618a7eb5 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -34,13 +34,13 @@ Both persistent and live FTS5 tables use `unicode61`. The implementation experim The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation. -One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. It never calls the backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP overlay records persisted availability, and the durable base refreshes after the live owner detaches. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. +One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. It passes the caller's exact abort signal into snapshot listing and non-mutating inspection, directly awaits every started backend operation, and checks cancellation after each await and before starting more work. Cancellation therefore rejects only after active backend work is quiescent, starts no subsequent observation or reconciliation step, and keeps a following search serialized behind cleanup even if a backend ignores the signal. The operation never calls the backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP overlay records persisted availability, and the durable base refreshes after the live owner detaches. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. The derived schema has its own application id and monotonic schema version. Persistent and TEMP session metadata store the integer `SessionHeader.createdAt` contract in strict `INTEGER` columns. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. On POSIX filesystems, missing directories and database files are created owner-only so new SQLite sidecars inherit that mode; existing modes are preserved. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned. -Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption. +Cancellation rejects queued operations promptly. Once asynchronous source observation starts, the caller waits for that backend promise to settle before rejection, without committing an aborted observation or starting more source/index work. Node's synchronous `DatabaseSync` metadata and MATCH calls cannot be interrupted once executing on the JavaScript thread, so the service checks the signal around those calls but does not promise mid-statement preemption. ## Alternatives considered @@ -54,6 +54,6 @@ Cancellation rejects queued operations and caller waits around asynchronous sour Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a lightweight snapshot read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Durable revisions avoid full-log reads and rewrites for unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable. -The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is effective while queued or awaiting sources, but synchronous SQLite execution remains a non-preemptible section. +The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is prompt while queued and quiescent while awaiting sources; synchronous SQLite execution remains a non-preemptible section bracketed by signal checks. Unit coverage pins extraction, filters, both search scopes, all default surfaces, metadata-before-ranking, snippets, literal escaping, deterministic ties, complete pagination, scoped cursor invalidation, dynamic persistence mount/unmount, restart reconciliation, live shadow/reveal/reopen, schema safety, rollback retry, and queued/in-flight source-wait cancellation. A keyless real-Loader-path test combines the package with the real SQLite persistence backend. diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml index f1b9829b73..2c0b4d3404 100644 --- a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.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-21-dsh-system-prompt-source-path.md: b54d01488fd7c0b49e06200c93af2b056c9fd00b -2026-07-21-dsh-system-prompt-source-path.zh.md: 208e3dce072f63c280999e15276dce62ff4e5c43 +2026-07-21-dsh-system-prompt-source-path.md: 4cb89e8124840bba6633235d195e95957245137c +2026-07-21-dsh-system-prompt-source-path.zh.md: 90c23bed4a3f95155e323c63a68fe2da09543ea6 diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md index b54d01488f..4cb89e8124 100644 --- a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md +++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md @@ -16,7 +16,7 @@ The testable logic lives in `dsh-app-boot`, not in `apps/cli`, because `apps/*` ## Scope -Only the `dsh` CLI adds this. The demo bins (`dsh-tui-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs. +Only the `dsh` CLI adds this. The demo bins (`dsh-cli-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs. ## HMR diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md index 208e3dce07..90c23bed4a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md @@ -16,7 +16,7 @@ Status: implemented ## Scope -只有 `dsh` CLI 会加入这一段。demo bin(`dsh-tui-demo`、`dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。 +只有 `dsh` CLI 会加入这一段。demo bin(`dsh-cli-demo`、`dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。 ## HMR diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml index 56333563f5..e5a2eb9f94 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.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-21-tui-no-banner.md: f5f4b1b847740e741ec3e33a6116e7497e955bd1 -2026-07-21-tui-no-banner.zh.md: 956fe03e2c0b09ea7378ffd53ffbe8d712d1e152 +2026-07-21-tui-no-banner.md: a6e0956f289cfc810da766fd0cae94b97baf5280 +2026-07-21-tui-no-banner.zh.md: acc5614727cf67881832af1685be557d675696e7 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md index f5f4b1b847..a6e0956f28 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md @@ -13,7 +13,7 @@ The TUI opened with a boxed product banner ("DEEPSEEK HARNESS" + model/session d ## Decision - `HeaderComponent`, the sweep animation, and its lifecycle wiring are deleted. The TUI mounts straight into the transcript; startup renders nothing above the separator. -- The model name moves into the footer status line's left segment (` ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, and `RESUME_SESSION_ID` consumers retrieve it there. +- The model name moves into the footer status line's left segment (` ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, where `dsh --resume ` and the `/resume` selector retrieve it. - `welcome`, when configured, renders as the transcript's first line (a muted notice) inside `rebuildTranscript`, so palette swaps preserve it. Unset renders nothing. Fixtures keep their configured welcomes; the PTY smoke's boot marker becomes the footer's model name, the only mounted-TUI text guaranteed to render regardless of cwd length. This supersedes the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md) entirely: both the sweep and the banner it animated are gone. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md index 956fe03e2c..acc5614727 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md @@ -13,7 +13,7 @@ TUI 启动时展示一个带框的产品横幅("DEEPSEEK HARNESS" + 模型/会 ## Decision - 删除 `HeaderComponent`、扫入动画及其生命周期接线。TUI 直接挂载进 transcript;启动时分隔线之上不渲染任何东西。 -- 模型名移入页脚状态行的左段(` ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`RESUME_SESSION_ID` 的使用者从那里获取。 +- 模型名移入页脚状态行的左段(` ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`dsh --resume ` 和 `/resume` 选择器会从中获取该 id。 - 配置了 `welcome` 时,它作为 transcript 的第一行(一条弱化的通知)在 `rebuildTranscript` 内渲染,因此调色板切换会保留它。未设置则什么也不渲染。fixture 保留各自配置的欢迎语;PTY 冒烟测试的启动标记改为页脚的模型名——无论 cwd 多长都保证渲染的唯一挂载后文本。 本 note 完全取代[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md):扫入动画和它所动画的横幅都已移除。 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml new file mode 100644 index 0000000000..9087db9dd7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.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-model-facing-session-query-tools.md: 2a9a20a8b39dea309e759f4eb6ddcdabe25dd8be +2026-07-24-model-facing-session-query-tools.zh.md: 7fbe746681b329b3e50ae74608a8b2e5167c5ae9 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md new file mode 100644 index 0000000000..2a9a20a8b3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -0,0 +1,55 @@ +# Agent Note: Model-facing session query tools + +Status: implemented + +English | [中文](2026-07-24-model-facing-session-query-tools.zh.md) + +## Problem + +The unified `ctx.sessionQuery` service exposes exact reads, filters, relationship traces, and full-text search over live-preferred session logs, but models cannot use that service directly. Giving a model the provider request types would also expose unstable pagination cursors, trusted corpus scope, storage-shaped time values, and result records that are more convenient for programmatic consumers than for reasoning. Large traces and event payloads introduce a separate output-size concern, but solving that concern inside this consumer would duplicate the harness-wide spill mechanism and make session-query tools disagree with other tools. + +## Decision + +`@deepseek-ai/dsh-tool-session-query` is the model-facing consumer of `ctx.sessionQuery`. It registers five narrow read-only tools: `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`. The package imports the interface rather than the SQLite implementation, owns model argument validation and readable text rendering, and contributes one concise prompt section that teaches the prior-history search and search-to-trace/read workflow. + +The package entrypoint is only the public composition root for configuration, prompt registration, and tool registration. Its internal modules follow the execution boundary: `input.ts` owns model schemas, normalization, and filter construction; `service-boundary.ts` contains provider calls and model-safe error translation; `workspace-access.ts` owns caller identity, workspace authorization, title access, and lineage projection; `operations.ts` orchestrates the five service workflows; and `presentation.ts` renders tool results and call cards. This keeps policy in its owning layer without changing the package contract. + +`session_search` groups full-text matches by session and exposes typed session and event metadata filters. `session_event_search` searches one session, defaulting to the caller's current session. `session_trace` returns the complete authorized ancestor chain and recursive descendant trees. `session_event_trace` returns every known positional replacement and direct provenance relationship for one event. `session_event_read` returns the exact target event as unabridged JSON and optionally summarizes a bounded raw-event window; omitted `before` and `after` values mean target-only. + +Model-facing filters use flat snake-case fields. Timestamps are timezone-qualified ISO 8601 strings at the tool boundary, convert to inclusive epoch-millisecond ranges for the service, and render as UTC ISO 8601. List values are ORed inside one filter while separate filters are ANDed. Requested parent ids are deduplicated and authority-filtered before FTS, so only parents in the caller workspace enter the provider clause; missing and cross-workspace guesses behave identically, while the root-session marker remains independently ORed into that clause. Event type strings remain open because `SessionEventMap` is merge-extensible; availability and event surface use closed values. + +## Workspace authority + +Every executor derives its caller from immutable `ToolExecution.exec.agent` identity and never accepts a model-supplied workspace. A target is authorized only when its observed `cwd` exactly equals the caller session's `cwd`. Cross-session search always adds that workspace filter. Direct operations preflight the target and then validate the header returned from the same service observation as every event-search page, event trace, event read, lineage target, or folded title before rendering its payload. This prevents a live or persisted target replacement between the check and use from crossing the workspace boundary. Lineage rendering stops at an unauthorized ancestor or descendant subtree without revealing the hidden session id. A caller whose session has no `cwd` can inspect only its own session; missing agent identity fails closed. + +Every trusted `ctx.sessionQuery` call crosses one model-boundary sanitizer. It checks the execution signal first, preserving caller cancellation exactly. For other failures it records the available corpus or provider diagnostic chain in the internal log on a best-effort basis, substituting a fixed placeholder when the value cannot be safely inspected. Diagnostic formatting and error classification are independently guarded, so an unprintable nested cause cannot escape or prevent a safely classified outer error, while unsafe classification or logging returns the fixed generic `SESSION_QUERY_TOOL_FAILED` code and message. Per-title failures use the same sanitizer before becoming unavailable markers. Tool-owned input-validation and authorization errors remain precise because they are created outside this service boundary. + +The search tools expose prior work rather than the operation that is performing the search. `session_search` omits the caller's session. When `session_event_search` targets the caller's session, it intersects the requested sequence range with the event immediately before the current `step/start`, excluding the current assistant message and tool call as well as the query arguments indexed from that call. + +## Cursor-free results and spill + +Neither search tool exposes a cursor, offset, page size, or model-controlled result limit. One execution follows provider cursors while the observed generation remains valid and collects up to the configured `maxSearchResults`, which defaults to 100. A capped result tells the model to narrow its query or filters; a generation change reports that the whole search must be retried. Search execution carries a configurable `searchTimeoutMs`, defaulting to 30 seconds, through the tool deadline and the service abort signal. Because internal pages share generation-bound cursors, both search tools are exclusive in the agent-loop scheduler; the exact trace and read tools opt into parallel sibling execution because their observations tolerate intervening commits. + +Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. Each exact executor passes its unchanged tool-execution signal through target authorization and the service trace or read. Within service resolution, known-live event traces, event reads, and title reads remain persistence-free while honoring pre-abort. Session lineage tracing passes the signal to whole-corpus persistence listing; persisted event tracing and reading pass it to target listing and inspection. Each started backend call is awaited for cleanup before the exact abort reason is preserved, even when that backend ignored cancellation. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. + +Session-level results include the latest folded title when available. Each tool execution batches its unique title ids through one live-preferred corpus observation with at most the service's configured `persistedInspectConcurrency` workers, which defaults to four, and passes the exact tool-execution signal through persisted listing and inspection. Live sources fold directly; each persisted worker folds its completed source to a detached header/title observation and releases the full log before dequeuing another id, so the batch retains only small projected values. For the search tools, the execution signal carries the configured search deadline. Cancellation starts no queued title inspections and rejects the complete tool execution after already-started inspections settle; a missing, malformed, or operationally failed title remains isolated to that id, preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. + +## Host composition + +The consumer is an opt-in plugin. The shipped ACP and TUI apps mount `ctx.sessionQuery` for non-model consumers, while the shared Web/headless composition mounts neither the query service nor the consumer. No shipped composition mounts `@deepseek-ai/dsh-tool-session-query`, so default model requests gain no query prompt or schemas. A composition that opts in also chooses whether to mount the generic timeout and spill policies; the dedicated ACP snapshot fixture mounts both and uses private local spill storage. Generic tool presentation requires no session-query-specific client plugin. + +## Alternatives considered + +- **Expose provider cursors to the model** — rejected because recording a tool result or starting the next model step changes the relevant session or global generation, so a cursor is usually stale before the model can reuse it. +- **Add tool-local truncation, offsets, or spill files** — rejected because the post-execute spill policy already owns complete-result retention and retrieval across tools. +- **Allow every persisted session or model-supplied workspace filters** — rejected because `ctx.sessionQuery` is a trusted service and the model-facing consumer must enforce the caller's authority boundary. +- **Combine search, tracing, and exact reads into one operation selector** — rejected because narrow names give the model clearer schemas, defaults, presentation intents, and follow-up choices. +- **Return only one lineage hop** — rejected because spill removes the inline-size motivation while one-hop output would omit relationships with no continuation path. + +## Verification + +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Shipped configuration, app composition tests, and assembled ACP request-header snapshots prove that the model-facing consumer remains absent while `ctx.sessionQuery` stays available where mounted. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. + +## Consequences + +Models gain provider-independent access to prior session work without receiving storage authority or continuation state. Search has a finite per-call work bound and may require a narrower query to reach matches beyond the first 100; search calls cannot overlap siblings, while exact observations retain parallel scheduling. Complete traces and event payloads may become spill references instead of inline text. Exact string `cwd` equality favors a conservative security boundary over resolving symlink-equivalent paths. Custom compositions may mount the tool without spill, but then they explicitly accept complete inline trace and read results. diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md new file mode 100644 index 0000000000..7fbe746681 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -0,0 +1,55 @@ +# Agent Note: 面向模型的会话查询工具 + +Status: implemented + +[English](2026-07-24-model-facing-session-query-tools.md) | 中文 + +## 问题 + +统一的 `ctx.sessionQuery` 服务对优先使用实时数据的会话日志提供精确读取、过滤、关系追踪与全文搜索,但模型无法直接使用该服务。若把提供方请求类型交给模型,还会暴露不稳定的分页游标、受信任的语料范围、存储形态的时间值,以及更适合程序化消费者而非模型推理的结果记录。大型追踪与事件负载另有输出大小问题,但若在该消费者内部解决,就会重复 harness 的通用 spill 机制,并使会话查询工具与其他工具的行为不一致。 + +## 决策 + +`@deepseek-ai/dsh-tool-session-query` 是 `ctx.sessionQuery` 面向模型的消费者。它注册五个职责单一的只读工具:`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`。该包依赖接口而非 SQLite 实现,负责模型参数校验与易读文本渲染,并贡献一个精简的提示词段,说明历史搜索以及从搜索转向追踪/读取的工作流。 + +该包入口仅作为配置、提示词注册与工具注册的公开组合根。内部模块沿执行边界划分:`input.ts` 负责模型 schema、规范化与过滤条件构造;`service-boundary.ts` 包含提供方调用与面向模型的安全错误转换;`workspace-access.ts` 负责调用者身份、工作区授权、标题访问与谱系投影;`operations.ts` 编排五个服务工作流;`presentation.ts` 渲染工具结果与调用卡片。这样可让策略留在其所属层,同时不改变包契约。 + +`session_search` 按会话聚合全文匹配,并公开带类型的会话与事件元数据过滤条件。`session_event_search` 搜索一个会话,默认目标为调用者的当前会话。`session_trace` 返回完整的已授权祖先链与递归后代树。`session_event_trace` 返回一个事件所有已知的位置替换关系与直接来源关系。`session_event_read` 以未删节 JSON 返回准确的目标事件,并可选择汇总一个有界的原始事件窗口;省略 `before` 与 `after` 时只返回目标。 + +面向模型的过滤条件使用扁平的 snake-case 字段。工具边界上的时间戳采用带时区的 ISO 8601 字符串,转换为服务使用的闭区间毫秒时间戳,并以 UTC ISO 8601 渲染。同一个过滤条件中的列表值按 OR 组合,不同过滤条件按 AND 组合。请求的父会话 id 会在 FTS 之前去重并按权限过滤,因此只有调用者工作区中的父会话会进入提供方条件;缺失与跨工作区的猜测具有相同行为,而根会话标记仍会独立按 OR 加入该条件。由于 `SessionEventMap` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。 + +## 工作区权限 + +每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标观测中的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件。直接操作先预检目标,然后在渲染负载前,校验与每一页事件搜索结果、事件追踪、事件读取、谱系目标或折叠标题来自同一服务观测的会话头。这样,即使实时或持久化目标在检查与使用之间被替换,也无法跨越工作区边界。谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。 + +每个受信任的 `ctx.sessionQuery` 调用都会经过同一个模型边界净化器。它首先检查执行信号,准确保留调用者取消。对于其他失败,它会尽力把可获得的语料或提供方诊断链写入内部日志;当值无法安全检查时,则改用固定占位符。诊断格式化与错误分类各自受到保护,因此无法打印的嵌套 cause 既不会逃逸,也不会阻止对外层错误进行安全分类;分类不安全或日志记录失败时,则返回固定的通用错误码 `SESSION_QUERY_TOOL_FAILED` 及其消息。逐标题失败也会先经过同一个净化器,再转为不可用标记。工具自身的输入校验与授权错误在该服务边界之外创建,因此仍保留精确消息。 + +搜索工具公开的是既往工作,而不是正在执行搜索的操作本身。`session_search` 排除调用者会话。`session_event_search` 以调用者会话为目标时,会把请求的序号范围与当前 `step/start` 之前的最后一个事件取交集,从而排除当前 assistant 消息、工具调用,以及从该次调用中建立索引的查询参数。 + +## 无游标结果与 spill + +两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。由于内部页面共享与代绑定的游标,两个搜索工具在 agent loop 调度器中都以独占方式执行;精确追踪与读取工具则允许和兄弟工具并行执行,因为其观测可以容忍期间发生的提交。 + +追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。每个精确执行器都会将未经替换的工具执行信号传递给目标授权与服务追踪或读取。在服务解析过程中,已知实时事件追踪、事件读取与标题读取在遵循预中止的同时仍不访问持久化。会话谱系追踪会将该信号传递给全语料持久化列表;持久化事件追踪与读取则将其传递给目标列表和检查。每个已启动的后端调用都会等待清理完成后再保留准确的中止原因,即使该后端忽略了取消也不例外。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 + +会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用服务通过 `persistedInspectConcurrency` 配置的持久化检查 worker,其默认值为 4,并将准确的工具执行信号传递给持久化列表与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 + +## 宿主组合 + +该消费方是一个需显式启用的插件。发布的 ACP 与 TUI 应用为非模型消费方挂载 `ctx.sessionQuery`,而 Web/headless 共享组合既不挂载查询服务,也不挂载该消费方。发布的组合均未挂载 `@deepseek-ai/dsh-tool-session-query`,因此默认模型请求中不包含查询提示词或 schema。选择启用该插件的组合还要决定是否挂载通用的超时与 spill 策略;专用的 ACP 快照 fixture(测试前置数据)同时挂载这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 + +## 考虑过的替代方案 + +- **向模型公开提供方游标**:不予采纳,因为记录工具结果或开始下一个模型步骤会改变相关会话或全局代,导致游标通常在模型能够复用前就已过期。 +- **增加工具本地截断、偏移量或 spill 文件**:不予采纳,因为执行后 spill 策略已经统一负责各工具的完整结果保留与读取。 +- **允许访问所有持久化会话或由模型提供工作区过滤条件**:不予采纳,因为 `ctx.sessionQuery` 是受信任服务,面向模型的消费者必须执行调用者权限边界。 +- **把搜索、追踪与精确读取合并为一个带操作选择器的工具**:不予采纳,因为职责单一的名称能为模型提供更清晰的 schema、默认值、表现意图与后续选择。 +- **只返回一层谱系**:不予采纳,因为 spill 已消除行内大小方面的理由,而单层输出会遗漏关系且没有继续读取路径。 + +## 验证 + +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置、应用组合测试与组装后的 ACP 请求头快照证明:面向模型的消费方仍未挂载,而 `ctx.sessionQuery` 在已经挂载该服务的组合中保持可用。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 + +## 后果 + +模型无需获得存储权限或继续状态,即可通过与提供方无关的方式访问既往会话工作。搜索具有有限的单次调用工作边界,若要命中前 100 条以后的结果,可能需要缩小查询;搜索调用不能与兄弟工具重叠执行,而精确观测仍可并行调度。完整追踪与事件负载可能表现为 spill 引用而不是行内文本。严格的 `cwd` 字符串相等选择了保守安全边界,而不解析通过符号链接等价的路径。自定义组合可以在不挂载 spill 的情况下使用该工具,但这表示它们明确接受完整追踪与读取结果直接出现在行内。 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/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index b21353698c..ffa1e87103 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.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-gui-testing-system.md: e42dafcdf37e48475e7d420eaad9600e6c20891c -2026-07-20-gui-testing-system.zh.md: e4ef6246e59e0ad6c0a3070a38c546f964e347fa +2026-07-20-gui-testing-system.md: 546f65f065c0c2266773acc3c28b2833a094ba9b +2026-07-20-gui-testing-system.zh.md: 6601ae0a1c2bd1671af6f02961fbda81d30ab971 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index e42dafcdf3..546f65f065 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -20,7 +20,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up: |---|---|---|---| | 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | -| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts` | +| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. @@ -33,15 +33,15 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes |---|---|---|---| | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source | | Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery | -| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 two-level smoke (fixture level + real-host level self-skip) | After touching the build surface/boot/carriage; before delivery | +| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery | | Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window | **Division of labor between the browser scripts and vitest**: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions. ## Anti-regression discipline -- **Every bug fix pins an assertion**: a browser-visible bug is pinned into the regression section of its owning verify script (one pin = one report line); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense). -- **All-green on fixture is not done, the real host must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run `verify-session-real`. +- **Every bug fix pins an assertion**: a browser-visible bug is pinned into its owning browser spec (smoke or e2e scenario); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense). +- **All-green on fixture is not done, the real wire must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run the browser lane (`pnpm run test:web`) — its keyless e2e scenarios drive the real HTTP/SSE carriage, and the with-key real-host smoke remains the live-model complement. - The code-on-disk-is-the-answer reconciliation workflow: when a behavior change lands and turns existing cases red, reconcile on the spot (fix the test or fix the code, with the RFC/contract as arbiter); no red left hanging. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index e4ef6246e5..6601ae0a1c 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -20,7 +20,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---|---|---| | 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | -| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过 | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts` | +| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | 层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。 @@ -33,15 +33,15 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---|---|---| | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 | | 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 | -| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层双级 smoke(fixture 级 + 真 host 级 self-skip) | 改构建面/boot/承载后;交付前 | +| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层浏览器全集:双级 smoke(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 | | 门禁 | `pnpm run test:coverage` | 全仓 gate(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR 窗口 | **浏览器脚本与 vitest 的分工**:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。 ## 防回归纪律 -- **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属 verify 脚本的回归节(一钉一行 report);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。 -- **fixture 全绿不算完,真 host 也要过**:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,`verify-session-real` 必跑。 +- **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属浏览器 spec(smoke 或 e2e 场景);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。 +- **fixture 全绿不算完,真 wire 也要过**:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,浏览器车道(`pnpm run test:web`)必跑——其无密钥 e2e 场景驱动真实 HTTP/SSE 承载,带密钥的真 host smoke 仍是真模型侧的补充。 - 落盘代码即答案的对表工作流:行为改动落盘打红既有用例时,当场对表校准(改测试还是改代码以 RFC/契约为裁),不留悬红。 ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml index 7b529ad317..a760a91a8f 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.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-retire-readline-front-door.md: 166e9ca17989ff14f9c3f38cd9650387581b0f78 -2026-07-20-retire-readline-front-door.zh.md: 8c2568f60c3a12fb16a9ef4fe1775e875966a49a +2026-07-20-retire-readline-front-door.md: bf2ff2e1498668e4a45b9fcf310d92b2f3ea3099 +2026-07-20-retire-readline-front-door.zh.md: ee22437a9e7ab8fbde612ba14f296c3d230928f1 diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md index 166e9ca179..bf2ff2e149 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md +++ b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md @@ -26,7 +26,7 @@ Pipes remain the default test medium. PTY-driven subprocess tests are sanctioned - `examples/echo-agent/tests/echo.e2e.ts` proves the Loader boot + mock-model tool round-trip through `stream-json` records instead of readline transcript lines. - The CI demo-smoke gate (`scripts/run-gates.ts`, AGENTS.md) runs `demo:echo --output-format stream-json -p "echo ci smoke"` and parses the records structurally. -- `packages/examples/tui-demo/tests/built-bin.e2e.ts` proves the built bin's piped-launch refusal (nonzero exit + pointer at `dsh-cli-demo`); the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite. +- The TUI's piped-launch refusal (nonzero exit + pointer at the one-shot CLI) is covered by `apps/cli/tests/built-bin.e2e.ts` (the `dsh` TTY guard under plain Node); the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite. - `packages/context/time-context/tests/time-context.e2e.ts` runs one one-shot turn; multi-turn elapsed rendering stays unit-covered in its spec. ## Accepted losses diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md index 8c2568f60c..ee22437a9e 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md @@ -26,7 +26,7 @@ Status: implemented - `examples/echo-agent/tests/echo.e2e.ts` 通过 `stream-json` 记录证明 Loader 启动 + mock 模型的工具往返,而不是匹配 readline 文本记录行。 - CI 演示冒烟门禁(`scripts/run-gates.ts`、AGENTS.md)运行 `demo:echo --output-format stream-json -p "echo ci smoke"` 并结构化解析记录。 -- `packages/examples/tui-demo/tests/built-bin.e2e.ts` 证明构建产物 bin 对管道启动的拒绝(非零退出 + 指向 `dsh-cli-demo` 的提示);纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。 +- TUI 对管道启动的拒绝(非零退出 + 指向单次任务 CLI 的提示)由 `apps/cli/tests/built-bin.e2e.ts`(纯 Node 下的 `dsh` TTY 守卫)覆盖;纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。 - `packages/context/time-context/tests/time-context.e2e.ts` 运行一个单次任务轮次;多轮 elapsed 渲染仍由其单元测试覆盖。 ## 接受的损失 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml new file mode 100644 index 0000000000..ef389fecb3 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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-web-gui-browser-e2e-lane.md: fb870c4bb2c85d8be7ac11f9f29f05bf24f446a4 +2026-07-24-web-gui-browser-e2e-lane.zh.md: c43e526d27fd4d8cf4f774e8a03480930682041d diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md new file mode 100644 index 0000000000..fb870c4bb2 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -0,0 +1,90 @@ +# Agent Note: Keyless browser e2e lane for the web GUI + +Status: implemented + +English | [中文](2026-07-24-web-gui-browser-e2e-lane.zh.md) + +## Problem + +The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → the host agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the keyless smoke drives `FixtureApiClient` — no host, no wire, no agent loop — while the full-chain smoke needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits. + +## Decision + +`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replayed through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, asserting a normalized conversation aria golden plus in-process world state. No new package; the product deltas are two additive `dsh-llm-replay` surfaces (`paceMs`, `ReplayHandle`). + +### Scaffold: `apps/web/tests/scaffold.ts` + +A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. + +`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. + +Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelContext` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. + +`seedSession()` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` backdate for deterministic sidebar order (the `semantic-checkpoint.snapshot.ts` precedent) — never raw file writes, so the seeder knows nothing of bucket hashing, filename encoding, or compression, and the host's zstd default needs no boot knob. Seeds are validated at seed time (parseable, ending in `turn/end` — an open final turn would be mutated by resume's crash repair). + +### Determinism rules + +The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); file polling is banned (slow on NFS, superseded by `whenIdle`); `networkidle` is banned outright (never resolves while an SSE stream is open). + +No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. + +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. + +### Expected outputs + +One committed golden per scenario: a normalized `ariaSnapshot()` of the conversation region (`ui.expected.md`) — uuid/cwd/workspace-basename/duration tokens normalized, captured poll-until-equal at the settled milestone — plus a few role/text anchor assertions that stay green under a semantics-preserving component rewrite while the golden churns reviewably. The aria tree is the mechanization of the client rule "assert what the user would see, never class names". World-state assertions ride root-context session events inline (which tool call produced which durable result, whether `turn/end` completed) instead of a second committed log golden: the persisted-log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence, and re-pinning it here would double refresh cost against the tier discipline. `refresh` is the sole golden writer — a missing golden in replay mode fails with the healing command rather than self-bootstrapping. + +The typecheck plane split is structural: the three files that boot the host spine (`scaffold`, `replay-round-trip.e2e`, and `seeded-history.e2e`) are excluded from the client-registered `apps/web` project. Those files and their shared `support.ts` are included file-by-file in `tsconfig.host.json` — one program cannot hold both sides of the cordis `Context` merges. + +### Modes and fixtures + +`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` tokenization; a follow-up keyless refresh regenerates `ui.expected.md`. Both scenarios' fixtures were recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point with no run-local `rpcId`). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred. + +### Scenarios + +1. **`replay-round-trip`** — new session, prompt through the real composer, replay streams reasoning + a `bash` tool call that really executes in the temp workspace + final text (paced 15ms). Asserts settled markdown, the aria golden, and inline world state (the bash call's durable result is exactly `WEB_E2E_OK\n`, completed `turn/end`, >10 chunk events). +2. **`seeded-history`** — a recorded session seeded cold; the sidebar lists it (group row → session row, collapsed by default), opening renders tool cards and text purely from the log through the implicit cold-resume attach inside `session.history` — zero model calls in replay, so no binding constraints; record mode drives the same turn live (real `read` tool against seeded workspace files) to produce the seed. + +### CI stance + +The lane ships gate-exempt inside `pnpm run test:web`, exactly as that config's header records. Adding chromium to CI would reverse the "no browser infrastructure in CI" premise in the [GUI testing note](../process/2026-07-20-gui-testing-system.md) and therefore requires its own Agent Note cross-linked from there, staged as a non-required job first with measured promotion criteria (consecutive green runs, wall time, zero-retry flake budget, runner browser-cache strategy). `TODO(ci-browser)` marks the seam. Scenarios are POSIX-oriented (the lane is not in the Windows matrix). + +## Prior art + +Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot + AI SDK, lobe-chat, open-webui, OpenHands, Chainlit, continue, cline, langfuse, gradio/streamlit; Playwright HAR/route, MSW, Polly/nock, WireMock, aimock). The dominant proven architecture for apps that own their backend is an in-process fake/replay model behind the real backend seam with everything downstream real (LibreChat's `LIBRECHAT_TEST_RUN_HOOK` fake model; ai-chatbot's `MockLanguageModelV3` + `simulateReadableStream`; continue's scripted mock provider classes) — which is what `dsh-llm-replay` already is. Browser-level SSE interception cannot exercise incremental rendering (`route.fulfill` delivers the whole body at once; playwright#33564) and leaves the server SSE stack untested, so projects use it only for edge cases. Chunk pacing as a fixture parameter recurs everywhere (LibreChat 10ms default with slow profiles; ai-chatbot 500ms); real models in CI rot (open-webui's suite grew 120-second timeouts, was disabled, then deleted); sessions are seeded at the persistence layer with controlled timestamps (LibreChat inserts backdated Mongo documents; langfuse seeds its DB). No surveyed project replays a recorded agent-event log through the real backend for UI tests — the closest are provider-level recorded fixtures (aimock) and frontend-level socket history emission (OpenHands MSW) — so the session-log-as-fixture design goes one step beyond prior art along the axis this repo's model-visible ⟺ logged invariant makes natural. + +## Alternatives considered + +**Browser-network SSE interception (`page.route`).** Rejected: `route.fulfill` cannot stream, so incremental token rendering is unexercisable and the server-side SSE/backpressure/close path — where both confirmed P0s hid — goes untested. + +**Mock HTTP provider at `DEEPSEEK_BASE_URL`.** Rejected as the lane's mechanism (kept for the one existing workspace-probe smoke): fixtures become hand-authored OpenAI SSE byte scripts, a second fixture format that drifts from the session-log format the rest of the repo records and replays; the adapter's real HTTP path is with-key e2e's job. + +**Growing the `?fixture` client.** Rejected: tier separation — `FixtureApiClient` exists to test the client shell without a server; everything below the client API seam stays untested by construction. + +**Placeholder `DEEPSEEK_API_KEY` + replay interception instead of disabling the adapter row.** Rejected despite zero composition change and two in-tree precedents: it satisfies `llm-deepseek`'s fail-loud key check with a lie and leaves a dead adapter mounted-but-intercepted; the disabled row (the ACP overlay's move) is honest keylessness and fails loud at the earliest resolvable point. + +**A `packages/support/web-snapshot` package with a `defineWebSnapshotSuite` factory.** Rejected: chromium-driving source cannot honestly hold per-file 100% coverage on browserless coverage runners, and at two scenarios a factory generalizes from one consumer while the genuinely shared logic is already exported from gated packages. Re-entry trigger: a second web-shaped consumer or ≥6 scenarios with demonstrably drifting inline branches; the package boundary would then be drawn browser-free. + +**A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on root-context events keep the world-verification duty. + +**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. + +**Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. + +**Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key W5 smoke stays as the live-model complement. + +**A client `data-dsh-busy` settled signal.** Deferred: the multi-condition settled polls proved sufficient at two scenarios and the host-side `whenIdle` barrier does the heavy lifting. Re-entry trigger: the first settled-poll flake, or a scenario needing a state the DOM does not expose. + +## Testing + +The lane itself: `pnpm run test:web` runs both scenarios keylessly alongside the existing smoke pair; `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` re-records a scenario's fixture against the live model; `DSH_SNAPSHOT=refresh` rewrites both aria goldens keylessly. `paceMs` validation, pacing floor, abort-during-pace, and both `assertConsumed` failure shapes are pinned in `packages/support/llm-replay/tests/llm-replay.spec.ts`. + +## Deferred + +- **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins the web composition's prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. +- **CI browser provisioning**: reversal of the no-browser-in-CI ruling, staged criteria above (`TODO(ci-browser)`). +- **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. + +## Consequences + +The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the lane guards regressions only where it runs (locally, `test:web`) until the CI reversal is separately decided. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md new file mode 100644 index 0000000000..c43e526d27 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -0,0 +1,90 @@ +# Agent Note: Web GUI 的无密钥浏览器 e2e 车道 + +Status: implemented + +[English](2026-07-24-web-gui-browser-e2e-lane.md) | 中文 + +## 问题 + +Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSE(Server-Sent Events)流 → `toFetchHandler`/apiproxy → host 端的 agent loop(智能体循环)、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。[GUI 测试体系](../process/2026-07-20-gui-testing-system.md)覆盖第 1 层(Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但无密钥冒烟驱动的是 `FixtureApiClient`——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 `DEEPSEEK_API_KEY` 和真实模型,因此不确定、在无密钥 CI 中自行跳过。[docs/testing.md](../../../../docs/testing.md) 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACP(Agent Client Protocol)、headless `stream-json` 与 TUI 三个文本记录(transcript)表面;web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处:fixture(测试前置数据)客户端短路掉的 wire 承载链。 + +## 决策 + +`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放,断言规范化后的会话区 aria 预期输出加进程内世界状态。不新增包(package);产品侧增量只有 `dsh-llm-replay` 的两处增量接口(`paceMs`、`ReplayHandle`)。 + +### Scaffold:`apps/web/tests/scaffold.ts` + +一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 + +`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 + +无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelContext` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 + +`seedSession()` 通过真实持久化 API 播种冷会话——一次性 `Context` 挂载 `SessionStore` + `SessionPersistenceJsonl` 指向 host 的根目录,`create()` + `append()`,一次 `utimes` 回拨保证侧栏顺序确定(`semantic-checkpoint.snapshot.ts` 先例)——绝不裸写文件,因此播种器对桶哈希、文件名编码、压缩一无所知,host 的 zstd 默认值也无需任何启动开关。种子在播种时即校验(可解析、以 `turn/end` 结尾——未闭合的最终轮次会被恢复(resume)的崩溃修复改写)。 + +### 确定性规则 + +回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);文件轮询被禁止(NFS 上慢,且被 `whenIdle` 取代);`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。 + +不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 + +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。 + +### 预期输出 + +每场景一份提交的预期输出:会话区规范化 `ariaSnapshot()`(`ui.expected.md`)——uuid/cwd/工作区目录名/时长归一为稳定 token,在安定里程碑处轮询至两次相等再采集——外加几条 role/文本锚断言,让保语义的组件重写在预期输出可评审地变动时仍保持绿色锚点。aria 树是 client 规则「断言用户所见,绝不断言类名」的机械化。世界状态断言内联在根上下文的会话事件上(哪次工具调用产生了哪项已持久化的工具结果、`turn/end` 是否完成)而不是第二份提交的日志预期输出:持久化日志表面已由 ACP/headless/TUI 套件经同一循环和持久化钉住,在此重复钉住会违背分层纪律、翻倍刷新成本。`refresh` 是预期输出的唯一写入者——回放模式下预期输出缺失会连同修复命令一起报错,而不是静默自举。 + +类型检查平面切分是结构性的:启动 host 主干的三个文件(`scaffold`、`replay-round-trip.e2e` 和 `seeded-history.e2e`)被排除出注册在 client 侧的 `apps/web` 工程。这三个文件及其共享的 `support.ts` 逐文件纳入 `tsconfig.host.json`——一个程序不能同时持有 cordis `Context` 合并的两侧。 + +### 模式与 fixture + +`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` token 化;随后一次无密钥 refresh 重新生成 `ui.expected.md`。两个场景的 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点,不含当次运行的 `rpcId`)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。 + +### 场景 + +1. **`replay-round-trip`**——新会话,经真实输入框发送提示词,回放流式输出推理(reasoning)+ 一次在临时工作区真实执行的 `bash` 工具调用 + 最终文本(15ms 节奏)。断言安定后的 markdown、aria 预期输出与内联世界状态(这次 bash 调用的已持久化工具结果严格等于 `WEB_E2E_OK\n`、完成的 `turn/end`、>10 个分片事件)。 +2. **`seeded-history`**——冷播种一份已录会话;侧栏列出它(分组行 → 会话行,默认折叠),打开后纯凭日志经 `session.history` 内的隐式冷恢复挂载渲染工具卡片与文本——replay 下零模型调用,因此没有任何绑定约束;record 模式实时驱动同一轮(真实 `read` 工具读取播种的工作区文件)来产出种子。 + +### CI 立场 + +车道随 `pnpm run test:web` 交付、豁免门禁,与该配置头部注释所记一致。往 CI 加 chromium 会推翻 [GUI 测试笔记](../process/2026-07-20-gui-testing-system.md)中「CI 无浏览器基础设施」的前提,因此需要自己的 Agent Note 并从那里交叉链接,分阶段推进:先作为非必需任务,再以量化标准晋升(连续绿色运行次数、耗时、零重试的抖动预算、runner 浏览器缓存策略)。`TODO(ci-browser)` 标记该接缝。场景目前面向 POSIX(车道不在 Windows 矩阵中)。 + +## 业界先例 + +调研了 AI 聊天/agent web UI 与 mock 层(LibreChat、vercel/ai-chatbot + AI SDK、lobe-chat、open-webui、OpenHands、Chainlit、continue、cline、langfuse、gradio/streamlit;Playwright HAR/route、MSW、Polly/nock、WireMock、aimock)。自有后端的应用的主流成熟架构是:真实后端 seam 后放一个进程内伪造/回放模型,下游全部真实(LibreChat 的 `LIBRECHAT_TEST_RUN_HOOK` 伪模型;ai-chatbot 的 `MockLanguageModelV3` + `simulateReadableStream`;continue 的脚本化 mock 提供方类)——这正是 `dsh-llm-replay` 已然所是。浏览器层 SSE 拦截无法检验增量渲染(`route.fulfill` 一次性交付整个响应体;playwright#33564),且服务端 SSE 栈完全失测,因此各项目只把它用于边缘用例。分片节奏作为 fixture 参数反复出现(LibreChat 默认 10ms 附慢速档;ai-chatbot 500ms);CI 里的真实模型会腐烂(open-webui 的套件长出 120 秒超时,先被禁用后被删除);会话在持久化层以受控时间戳播种(LibreChat 直插回拨时间的 Mongo 文档;langfuse 播种其数据库)。没有任何被调研项目为 UI 测试把录制的 agent 事件日志经真实后端回放——最接近的是提供方层录制 fixture(aimock)与前端层 socket 历史发射(OpenHands MSW)——因此会话日志即 fixture 的设计沿着本仓库「模型可见 ⟺ 已记录」不变式所指的方向比业界先例多走了一步。 + +## 曾考虑的替代方案 + +**浏览器网络层 SSE 拦截(`page.route`)。** 已否决:`route.fulfill` 无法流式输出,增量 token 渲染无从检验,且服务端 SSE/背压/关闭路径——两起已实证 P0 的藏身处——完全失测。 + +**`DEEPSEEK_BASE_URL` 处的 mock HTTP 提供方。** 作为本车道机制已否决(仅保留给既有的工作区探针冒烟):fixture 会变成手写的 OpenAI SSE 字节脚本,一种与仓库其余部分录制回放的会话日志格式渐行渐远的第二 fixture 格式;适配器的真实 HTTP 路径归带密钥 e2e 管。 + +**扩展 `?fixture` 客户端。** 已否决:分层纪律——`FixtureApiClient` 的存在意义就是脱离服务器测试客户端 shell;client API seam 以下按构造即失测。 + +**用占位 `DEEPSEEK_API_KEY` + 回放拦截替代禁用适配器行。** 尽管零组合改动且树内有两处先例仍被否决:它用谎言满足 `llm-deepseek` 的快速失败密钥检查,还留下一个挂载却被拦截的死适配器;禁用行(ACP overlay 的同款做法)是诚实的无密钥,并在最早可解析点快速失败。 + +**`packages/support/web-snapshot` 包 + `defineWebSnapshotSuite` 工厂。** 已否决:驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100%,且两个场景就上工厂是从单一消费方过度泛化,真正共享的逻辑已从受门禁的包中导出。重启条件:出现第二个 web 形态消费方,或 ≥6 个场景的内联分支被证实各自漂移;届时包边界将画在无浏览器一侧。 + +**第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。 + +**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 + +**为可测试性改 wire 协议。** 已否决:契约已有第一等的无密钥同构 seam(`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。 + +**以真实模型浏览器测试充当无密钥车道。** 已否决:按构造即不确定;被调研的前车之鉴(open-webui)长出无界超时后被删除。带密钥的 W5 冒烟仍是真实模型侧的补充。 + +**客户端 `data-dsh-busy` 安定信号。** 暂缓:两个场景下多条件安定轮询已经够用,host 侧 `whenIdle` 屏障承担了重活。重启条件:第一次安定轮询抖动,或某场景需要等待 DOM 不暴露的状态。 + +## Testing + +车道自身:`pnpm run test:web` 与既有冒烟对一起无密钥运行两个场景;`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型重录某场景的 fixture;`DSH_SNAPSHOT=refresh` 无密钥重写两份 aria 预期输出。`paceMs` 校验、节奏下限、节奏中中止、`assertConsumed` 的两种失败形态钉在 `packages/support/llm-replay/tests/llm-replay.spec.ts`。 + +## 暂缓 + +- **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 web 组合的提示词/工具 schema(`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 +- **CI 浏览器供给**:推翻 CI 无浏览器裁定,分阶段标准见上(`TODO(ci-browser)`)。 +- **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 + +## 后果 + +Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;在 CI 反转被单独决策之前,车道只在其运行之处(本地,`test:web`)把守回归。 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml new file mode 100644 index 0000000000..c9cebc9db9 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.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-scriptable-llm-wire-fault-server.md: 0795f71f0eab1a107740aaa8cba6fa04b1fbd306 +2026-07-25-scriptable-llm-wire-fault-server.zh.md: a0e3c98d729adcc74e6d98933ab2beb538f71cb3 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md new file mode 100644 index 0000000000..0795f71f0e --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md @@ -0,0 +1,41 @@ +# Agent Note: Scriptable LLM wire fault server + +Status: implemented + +English | [中文](2026-07-25-scriptable-llm-wire-fault-server.zh.md) + +## Problem + +Adapter unit tests use local HTTP servers to classify individual provider failures, while retry tests use an in-process scripted `LlmAdapter` to prove closed-step recovery. Neither boundary provides a reusable server for running the shipping HTTP adapter, agent loop, and retry policy together, and neither lets a developer point an existing app at deterministic transport faults by changing only its base URL and API key. + +Connection refusal, a reset before the first event, clean EOF without `[DONE]`, a valid content-less completion, and a reset after partial output have different adapter and recovery outcomes. Treating them as one generic mock failure hides whether the provider boundary preserved the distinction and whether failed chunks remained outside committed model history. + +## Decision + +`@deepseek-ai/dsh-llm-mock-server` is a support package with an importable Node HTTP server and a standalone CLI. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`. + +Request behaviors cover socket reset, post-header disconnect, partial disconnect, stall, valid empty completion, clean truncated streams, malformed payloads, representative HTTP failures, complete text/reasoning/tool-call responses, slow streaming, and max-token completion. A true `connection_refused` is a CLI listener-lifecycle phase because a bound request handler cannot refuse its own TCP connection. + +The `random` script entry performs a new weighted selection for every request. The server exposes and logs its unsigned 32-bit seed, accepts caller-supplied relative weights, and ships a success-heavy stress profile that mixes transport, protocol, provider, timeout, and semantic-empty outcomes. The profile is configurable test pressure rather than an estimate of production incident frequency; `connection_refused` remains outside the request-level pool. + +The server reports wire facts only and does not classify retryability. Real-composition tests route it through `dsh-llm-deepseek`, `dsh-agent-loop`, and `dsh-llm-retry`: connection refusal, hard disconnect, partial reset, idle timeout, and a valid content-less completion recover under the existing default policy; clean partial EOF remains `STREAM_CLOSED` and is not retried by default. The package does not change those policies. + +## Verification + +Package tests exercise every request behavior, split UTF-8 request decoding, HTTP validation without script consumption, script exhaustion/repetition, stalled-connection teardown, CLI parsing and delay bounds, IPv6 base URLs, random seed reproducibility, weight validation, single-result telemetry, lifecycle cleanup, and the invariant companion under the per-file coverage gate. The retry integration suite proves exact request counts, numbered retry steps, request-body identity, failed partial-chunk isolation, semantic-empty recovery, clean-EOF classification, timeout recovery, true refused-connection recovery after delayed listener startup, and bounded exhaustion through the real HTTP/SSE adapter. + +## Alternatives considered + +**Implement the server in Python** — rejected because Node's standard HTTP and socket APIs expose every required fault, while TypeScript keeps the server, CLI parser, tests, package build, lint, and coverage inside the repository's existing toolchain. A second runtime would add environment and subprocess dependencies without increasing wire isolation. + +**Keep separate inline mock servers in adapter tests** — rejected because those fixtures cannot be launched by an existing app and would duplicate behavior sequencing, randomization, telemetry, and connection cleanup across suites. A support package gives tests a shared implementation without promoting it to product API. + +**Use only an in-process `LlmAdapter` mock** — rejected because it bypasses fetch, HTTP status/header parsing, SSE framing, socket termination, and the adapter idle watchdog: the exact boundaries this test infrastructure exists to exercise. + +**Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Extending recovery to `STREAM_CLOSED` requires a separate decision with its own cost, latency, and duplicate-generation trade-offs. + +## Consequences + +Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and recovered empty completions without splicing attempts or modifying model history. + +The server adds a support package, executable build entry, and behavior vocabulary that must remain compatible with both direct tests and CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval. diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md new file mode 100644 index 0000000000..a0e3c98d72 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 可脚本控制的 LLM(大语言模型)协议层故障服务器 + +Status: implemented + +[English](2026-07-25-scriptable-llm-wire-fault-server.md) | 中文 + +## 问题 + +适配器单元测试使用本地 HTTP 服务器对各类提供方故障逐一分类,重试测试则使用进程内的脚本化 `LlmAdapter` 证明已关闭步骤的恢复能力。这两个边界都无法提供可复用的服务器,以便同时运行交付版本的 HTTP 适配器、agent loop(智能体循环)和重试策略;开发者也无法仅修改现有应用的 base URL 与 API key,就让应用连接到确定性的传输故障。 + +连接遭拒、首个事件前连接被重置、未收到 `[DONE]` 即正常 EOF、合法但无内容的完成,以及输出部分内容后连接被重置,会产生不同的适配器与恢复结果。把它们统一视为普通 mock 故障,会掩盖提供方边界是否保留了这些区别,以及失败请求的分片是否确实没有进入已提交的模型历史。 + +## 决策 + +`@deepseek-ai/dsh-llm-mock-server` 是一个支持包(package),提供可导入的 Node HTTP 服务器和独立 CLI(命令行界面)。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。 + +请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI 的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。 + +脚本项 `random` 会为每个请求重新执行一次加权选择。服务器公开并记录其无符号 32 位 seed,允许调用方提供相对权重,并内置一套偏重成功结果的压力测试配置,将传输、协议、提供方、超时和语义空结果混合在一起。该配置用于提供可调的测试压力,并非对生产事故发生频率的估算;`connection_refused` 仍不进入请求级随机池。 + +服务器只报告协议层事实,不判断是否可重试。真实组合测试让请求依次经过 `dsh-llm-deepseek`、`dsh-agent-loop` 和 `dsh-llm-retry`:在现有默认策略下,连接遭拒、硬断开、部分输出后重置、空闲超时以及合法的无内容完成均可恢复;正常关闭的部分输出 EOF 仍归类为 `STREAM_CLOSED`,默认不重试。该包不会改变这些策略。 + +## 验证 + +包测试覆盖所有请求行为、跨分片 UTF-8 请求解码、不消耗脚本的 HTTP 校验、脚本耗尽与重复、停滞连接清理、CLI 解析及延迟边界、IPv6 base URL、随机 seed 可复现性、权重校验、单结果遥测、生命周期清理,以及逐文件覆盖率门禁下的配套不变式插件。重试集成套件通过真实 HTTP/SSE(Server-Sent Events)适配器,验证准确的请求次数、带编号的重试步骤、请求体完全一致、失败的部分分片不会泄漏、语义空结果恢复、正常 EOF 分类、超时恢复、监听器延迟启动后从真实连接遭拒中恢复,以及有界重试耗尽。 + +## 曾考虑的替代方案 + +**使用 Python 实现服务器**:不予采纳。Node 的标准 HTTP 与 socket API 足以暴露所有所需故障,而 TypeScript 可以让服务器、CLI 解析器、测试、包构建、lint 和覆盖率全部留在仓库现有工具链中。引入第二套运行时会增加环境与子进程依赖,却不能增强协议隔离。 + +**在适配器测试中继续使用各自独立的内联 mock 服务器**:不予采纳。这些 fixture(测试前置数据)无法作为独立服务器启动并供现有应用连接,还会让不同测试套件重复实现行为编排、随机化、遥测和连接清理。支持包让测试共享同一套实现,又不会将其提升为产品 API。 + +**仅使用进程内的 `LlmAdapter` mock**:不予采纳。它会绕过 fetch、HTTP 状态与 header 解析、SSE 分帧、socket 终止以及适配器的空闲看门狗,而这正是这套测试基础设施要覆盖的边界。 + +**随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否将恢复能力扩展到 `STREAM_CLOSED`,需要单独决策,并权衡成本、延迟和重复生成风险。 + +## 后果 + +开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与恢复后的空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。 + +服务器新增了一个支持包、可执行构建入口和行为词汇,三者必须同时兼容直接测试与 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。 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..4ff9034dd3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,16 +1,18 @@ # `@deepseek-ai/dsh` -The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. +The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. + +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. The TUI surface: -- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session; +- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); +- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - 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..8799669e8d 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,7 +72,9 @@ "@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:^", + "commander": "^15.0.0", "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/args.ts b/apps/cli/src/args.ts new file mode 100644 index 0000000000..9fd0f4d9bf --- /dev/null +++ b/apps/cli/src/args.ts @@ -0,0 +1,143 @@ +/** + * Commander adapter for the `dsh` command-line entry: the one place argv is + * parsed and routed to a mode. `bin.ts` switches on the returned discriminant + * and dynamic-imports that mode's module. One program: the default (no + * subcommand) is the TUI/headless surface with option-only flags; `web` is a + * real subcommand. Commander owns `--help`/`--version` and parse errors — it + * prints and exits at the point of failure (a domain failure routes through + * `command.error`), so this returns only a resolved mode. + * @module @deepseek-ai/dsh/args + */ + +import { Command, CommanderError } from 'commander' + +/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ +interface TuiInvocation { + mode: 'tui' + config?: string + resume?: string +} + +/** Headless one-shot: `dsh -p "task"`. */ +interface HeadlessInvocation { + mode: 'headless' + prompt: string +} + +/** + * Browser UI: `dsh web`. `host`/`port` are present only when the flag was + * passed — pass-through overrides with no CLI default and no CLI validation: + * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal, + * `port` a natural ≤ 65535) is the single source of both the default (the + * shipped `cordis.yml` value stands when a flag is absent) and validity (a bad + * value fails loud at boot). `port` is `Number`-coerced only because the schema + * wants a number, not a string. `dev` mounts the client HMR driver; + * `workspaceRoot` is the parent directory for name-created workspaces. + */ +interface WebInvocation { + mode: 'web' + host?: string + port?: number + dev: boolean + workspaceRoot?: string +} + +/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ +export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation + +/** Raw web-subcommand options straight from Commander. */ +interface WebOptions { + host?: string + port?: string + dev?: boolean + workspaceRoot?: string +} + +/** + * Narrow the raw `web` options into a {@link WebInvocation}. No host/port + * validation: both flow to the webserver schema, which is the sole gate. `port` + * is coerced to a number (the schema rejects a string) but not range-checked + * here — `NaN`/out-of-range fail loud at the schema on boot. + */ +function resolveWeb(options: WebOptions): WebInvocation { + return { + mode: 'web', + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + dev: options.dev === true, + ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, + } +} + +/** + * Resolve the raw argv into a {@link DshInvocation}, or print and exit for + * `--help`/`--version`/a parse error. The default (no subcommand) is the + * TUI/headless surface; `web` is a subcommand. + * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`). + * @param version - the version string `--version` prints; read from this app's package.json. + * @returns the resolved invocation (only reached on a valid, non-help invocation). + */ +export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { + let resolved: DshInvocation | undefined + const program = new Command() + .name('dsh') + .version(version, '-V, --version', 'output the version number') + .description('dsh: interactive TUI (default), headless task, and browser UI') + .exitOverride() + // Default surface: option-only (no positional), so `web` can be a real + // subcommand without a positional collision. + .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') + .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') + .option('--resume ', 'resume the persisted session with this id (TUI mode)') + .action((options: { config?: string; prompt?: string; resume?: string }) => { + if (options.prompt !== undefined) { + // A headless prompt owns the invocation; an empty task has nothing to + // run, and --config/--resume are TUI inputs that must not silently + // vanish from a headless run. + if (options.prompt === '') program.error('error: --prompt needs a task') + if (options.config !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config or --resume') + } + resolved = { mode: 'headless', prompt: options.prompt } + return + } + // An empty --resume= id would silently start a fresh session downstream + // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. + if (options.resume === '') program.error('error: --resume needs a session id') + resolved = { + mode: 'tui', + ...options.config !== undefined && { config: options.config }, + ...options.resume !== undefined && { resume: options.resume }, + } + }) + + const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + web + .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') + .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') + .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') + .option('--workspace-root ', 'parent directory for name-created workspaces') + .action((options: WebOptions) => { + // Commander parses the parent (default-surface) options on either side of + // the subcommand into `program.opts()`. `web` shares none of them, so a + // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must + // fail loud rather than silently start the web server and drop it. + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { + program.error('error: web takes none of --config, -p/--prompt, or --resume') + } + resolved = resolveWeb(options) + }) + + try { + program.parse(argv, { from: 'user' }) + } catch (error) { + // Commander printed help/version/the error under `exitOverride`; exit with + // the code it chose (0 for help/version, 1 for a parse or domain error). + /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */ + return process.exit(error instanceof CommanderError ? error.exitCode : 1) + } + /* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */ + if (resolved === undefined) throw new Error('dsh: no invocation resolved') + return resolved +} diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 1192472b98..f9e1eefc9b 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -1,25 +1,49 @@ #!/usr/bin/env node /** - * dsh — command-line entry. Coarse dispatch only; each surface module owns its - * argument handling. Dynamic imports keep unrelated surfaces out of each - * dispatch path; everything except `web` and headless prompts opens the TUI. + * dsh — command-line entry. Dynamic imports per mode keep unrelated modes out + * of each dispatch path; the adapter prints and exits for + * `--help`/`--version`/a parse error, so only a valid mode reaches the switch. * @module @deepseek-ai/dsh/bin */ /* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { parseDshArgs } from './args.ts' + +// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit +// one directory under apps/cli, so the checked-in manifest resolves with the +// same relative hop from either artifact. +/** This app's version, read from its checked-in package.json. */ +function readVersion(): string { + const manifest = JSON.parse( + readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'), + ) as { version?: unknown } + return typeof manifest.version === 'string' ? manifest.version : '0.0.0' +} loadEnv('dsh') -const argv = process.argv.slice(2) +const invocation = parseDshArgs(process.argv.slice(2), readVersion()) -if (argv[0] === 'web') { - const { runWeb } = await import('./web.ts') - await runWeb(argv.slice(1)) -} else if (argv.includes('-p') || argv.includes('--prompt')) { - const { runHeadless } = await import('./headless.ts') - await runHeadless(argv) -} else { - const { runTui } = await import('./tui.ts') - await runTui(argv) +switch (invocation.mode) { + case 'web': { + const { runWeb } = await import('./web.ts') + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot) + break + } + case 'headless': { + const { runHeadless } = await import('./headless.ts') + await runHeadless(invocation.prompt) + break + } + case 'tui': { + const { runTui } = await import('./tui.ts') + await runTui(invocation.config, invocation.resume) + break + } + default: + invocation satisfies never + throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 303bac61f8..111a41d4e3 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -1,18 +1,19 @@ /** - * `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 { @@ -65,28 +66,26 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, return { text, reason: 'error' } } -export async function runHeadless(argv: string[]): Promise { - const { values } = parseArgs({ - args: argv, - options: { prompt: { type: 'string', short: 'p' } }, - allowPositionals: false, - }) - const task = values.prompt - if (task === undefined || task === '') { - process.stderr.write('usage: dsh -p "task"\n') - process.exit(1) - } - +/** + * Run one headless turn for `task` and exit (completed → 0, else 1). The task + * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt` + * (the adapter rejects an empty task, so no guard is needed here). + * @param task - the prompt text for the single turn. + */ +export async function runHeadless(task: 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 +98,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/tui.ts b/apps/cli/src/tui.ts index 4a4ca8d7fc..4283668189 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,6 +1,6 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped - * tui-agent config (or an explicit config argument) with the personal overlay + * tui-agent config (or the `--config` override) with the personal overlay * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking @@ -18,8 +18,7 @@ import { installFailLoud, loadEnv, loadPersonalPatches, - parseResumeArg, - replaceResumeArg, + RESUME_SESSION_ID_KEY, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -28,12 +27,6 @@ import type { TuiResumeHost } from '@deepseek-ai/dsh-tui' const NAME = 'dsh' -// The env var the shipped tui-agent config reads (`resumeSessionId: !!js -// process.env.RESUME_SESSION_ID`) to rehydrate a persisted session. The -// `--resume ` flag is CLI sugar that sets it before boot, so the printed -// `dsh --resume ` exit hint runs back through this same intake. -const RESUME_SESSION_ID_ENV = 'RESUME_SESSION_ID' - // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit // one directory under apps/cli, so the shipped default config resolves with // the same relative hop from either artifact. @@ -48,26 +41,30 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) the tui-agent PTY smoke drives this path end to end, personal overlay included */ /** * Run the interactive TUI from the invoking directory. - * @param argv - arguments after the subcommand dispatch; a `--resume ` flag - * resumes that persisted session, and the first non-flag argument may name a - * config to boot instead of the shipped default. + * @param config - a config path to boot instead of the shipped default, or + * `undefined` for the default; already parsed from `--config`. + * @param resumeSessionId - a persisted session id to resume, or `undefined`; + * already parsed and non-empty-validated from `--resume`. It is provided on the + * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config + * reads through `!!js` to rehydrate that session. */ -export async function runTui(argv: string[]): Promise { +export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise { // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree // is logged per-entry rather than rethrown, so a piped launch would // otherwise settle into an idle UI-less process instead of exiting nonzero. if (!process.stdin.isTTY || !process.stdout.isTTY) { - process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs\n`) + process.stderr.write( + `${NAME}: the TUI requires stdin and stdout to be interactive TTYs; use \`${NAME} -p "task"\` for pipes and automation\n`, + ) process.exit(1) } installFailLoud(NAME) // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). loadEnv(NAME, resolveDshHome()) - // An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it - // after loadEnv and before boot reads it through the config's `!!js`. - const { resumeSessionId, rest } = parseResumeArg(argv) - if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId + // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` + // flag, so the resumed process rehydrates through this same intake. The host + // is offered only when Node exposes `process.execve` and knows its own entry. const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} @@ -75,13 +72,15 @@ export async function runTui(argv: string[]): Promise { async handoff(sessionId): Promise { const current = app.current if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) + // Rebuild argv from the parsed config plus the selected id: TUI mode's + // only arguments are `--config ` and `--resume `. const nextArgv = [ process.execPath, ...process.execArgv, entry, - ...replaceResumeArg(process.argv.slice(2), sessionId), + `--resume=${sessionId}`, + ...config !== undefined ? ['--config', config] : [], ] - process.env[RESUME_SESSION_ID_ENV] = sessionId try { await current.fiber.dispose() execve(process.execPath, nextArgv, process.env) @@ -94,9 +93,12 @@ export async function runTui(argv: string[]): Promise { } const ctx = await boot( NAME, - resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), + resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME), (hostCtx) => { + // Inject the resume id (or undefined) so the shipped config's `!!js` + // reads it as a bare identifier; then offer the in-place handoff host. + hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) }, ) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index bcd1482df3..31282c8f5f 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,49 +1,43 @@ /** - * `dsh web` — thin bin over the config-tree boot: parse argv, run - * AppCLIEntry, print the URL line, wire signals. All composition lives in - * cordis.yml; all boot glue lives in AppCLIEntry. + * `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the + * already-parsed host/port/dev, print the URL line, wire signals. All + * composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and + * port are unvalidated pass-through overrides — the `dsh-host-webserver` schema + * gates them at boot. */ -import { parseArgs } from 'node:util' import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import { AppCLIEntry } from './app-cli-entry.ts' +const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) + +// Display-only mirrors of the webserver schema's allowed hosts: the loopback +// address the local URL always prints, and the all-interfaces value that gates +// LAN-address discovery. Not a source of truth — the schema is. const LOOPBACK_HOST = '127.0.0.1' const ALL_INTERFACES_HOST = '0.0.0.0' -const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) - -export async function runWeb(argv: string[]): Promise { - const { values } = parseArgs({ - args: argv, - options: { - host: { type: 'string' }, - port: { type: 'string' }, - dev: { type: 'boolean', default: false }, - }, - allowPositionals: false, - }) - if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { - process.stderr.write( - `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`, - ) - process.exit(1) - } - let port: number | undefined - if (values.port !== undefined) { - port = Number(values.port) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - process.stderr.write(`dsh web: invalid --port ${values.port}\n`) - process.exit(1) - } - } - +/** + * Serve the browser UI from the shipped config tree. `host`/`port` are passed + * through only when the flag was given; absent, the `cordis.yml` value stands. + * @param host - the bind host, or `undefined` to keep the config default. + * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. + * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. + * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. + */ +export async function runWeb( + host: string | undefined, + port: number | undefined, + dev: boolean, + workspaceRoot: string | undefined, +): Promise { const entry = new AppCLIEntry({ configPath: CONFIG_PATH, - dev: values.dev, - ...values.host !== undefined ? { host: values.host } : {}, - ...port !== undefined ? { port } : {}, + dev, + ...host !== undefined && { host }, + ...port !== undefined && { port }, + ...workspaceRoot !== undefined && { workspaceRoot }, }) const { ctx, port: boundPort } = await entry.run() @@ -54,7 +48,7 @@ export async function runWeb(argv: string[]): Promise { void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lanCandidate = values.host === ALL_INTERFACES_HOST + const lanCandidate = host === ALL_INTERFACES_HOST ? Object.values(networkInterfaces()).flat() .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) : undefined diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts new file mode 100644 index 0000000000..052e96e9a0 --- /dev/null +++ b/apps/cli/tests/args.spec.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parseDshArgs } from '../src/args.ts' + +const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') + +/** + * `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets + * Commander print to the real streams; capture the exit code and mute output. + */ +function exitCode(argv: string[]): number { + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + parse(argv) + throw new Error(`expected ${JSON.stringify(argv)} to exit`) + } catch { + return exit.mock.calls.at(-1)?.[0] as number + } finally { + vi.restoreAllMocks() + } +} + +afterEach(() => { vi.restoreAllMocks() }) + +describe('parseDshArgs', () => { + it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { + expect(parse([])).toEqual({ mode: 'tui' }) + expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) + expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) + expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + // Bare `web` carries no host/port: the shipped cordis.yml owns the default. + expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) + // Host/port are unvalidated pass-throughs (the webserver schema gates them + // at boot); the adapter only coerces the port string to a number. + expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) + .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + }) + + it('exits nonzero instead of silently starting fresh or dropping inputs', () => { + // Empty resume/prompt would be swallowed downstream; --prompt mixed with + // TUI inputs must not lose them. (Bad host/port are gated by the webserver + // schema at boot, not here.) + expect(exitCode(['--resume='])).toBe(1) + expect(exitCode(['-p', ''])).toBe(1) + expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) + expect(exitCode(['--bogus'])).toBe(1) + expect(exitCode(['bogus-positional'])).toBe(1) + // A default-surface flag on either side of `web` leaks into program.opts() + // but the web subcommand shares none of them: reject rather than serve. + expect(exitCode(['web', '-p', 'task'])).toBe(1) + expect(exitCode(['web', '--resume', 's'])).toBe(1) + expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + }) + + it('exits 0 for --help (disclosing web) and --version', () => { + expect(exitCode(['--help'])).toBe(0) + expect(exitCode(['--version'])).toBe(0) + }) +}) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts new file mode 100644 index 0000000000..9fd1d55ab2 --- /dev/null +++ b/apps/cli/tests/built-bin.e2e.ts @@ -0,0 +1,55 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under + * plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot. + * `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a + * piped launch must exit nonzero with a stderr pointer at the one-shot `-p` + * mode. The guard fires inside `runTui` BEFORE the Loader resolves the config + * tree — a compose-time throw inside the tree is logged per-entry, not + * rethrown, so without this guard a piped launch would settle into an idle + * UI-less process. The bin resolves its workspace deps through the repo's + * node_modules, so no external consumer is assembled; missing-config fail-loud + * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's + * built-bin suite, and interactive TTY behavior is PTY-covered by + * examples/tui-agent. Skips before the bin is built. + */ + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') + +/** Run the built bin with PIPED stdio; resolve with output + exit code. */ +function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (c: string) => { stdout += c }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (c: string) => { stderr += c }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, 25_000) + // Resolve on `close` (all stdio drained), not `exit`, so captured output is complete. + child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) + child.on('error', (err) => { clearTimeout(timer); reject(err) }) + child.stdin.end() + }) +} + +describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { + it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => { + const { stdout, code, stderr } = await runBuiltBin() + expect(code).not.toBe(0) + expect(stderr).toContain('requires stdin and stdout to be interactive TTYs') + expect(stderr).toContain('dsh -p') + // The refusal happens before any plugin mounts: stdout stays silent. + expect(stdout).toBe('') + }, 30_000) +}) 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/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts new file mode 100644 index 0000000000..131f3fa5fd --- /dev/null +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -0,0 +1,129 @@ +// Web e2e scenario: fresh round trip. A real chromium types a prompt into the +// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo +// in the temp workspace) all run; the model seam is dsh-llm-replay (keyless) +// or the live adapter (record). Drive steps run in every mode and wait only +// on generic completion (whenTurnSettled — never model-content selectors, so +// record cannot hang on a live model answering differently); assertion steps +// run in replay/refresh only. Settled states only — streaming incrementality +// is asserted from the persisted assistant/chunk events, not transient DOM. +// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless +// DSH_SNAPSHOT=refresh regenerates ui.expected.md. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +// The scenario's one drive prompt. Record sends it; replay asserts the +// committed fixture recorded exactly it, so drive script and fixture cannot +// drift apart. +const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.' + +describe('web e2e: fresh round trip through the real assembly', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip')) + if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + // Arm the host-side settled barrier BEFORE the send click. + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 200_000) + + it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled')) + // Browser settled-poll after host completion (host strictly precedes render). + await page.locator('[data-streaming="true"]').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => { + // Chunks may coalesce into one commit; a never-mounted streaming node is + // legal — the chunk-event assertions below carry incrementality. + }) + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // World state, not self-report: the real bash executor returned the exact + // command output, and the turn closed cleanly. + const bashCall = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'bash') + if (bashCall?.type !== 'tool/call') throw new Error('the replayed turn did not call the bash tool') + const bashResult = sessionEvents.find(event => + event.type === 'tool/result' && event.data.callId === bashCall.data.callId) + if (bashResult?.type !== 'tool/result') throw new Error('the bash tool call produced no durable result') + expect(bashResult.data.isError).toBe(false) + expect(bashResult.data.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toBe('WEB_E2E_OK\n') + const turnEnds = sessionEvents.filter(e => e.type === 'turn/end') + expect(turnEnds.length).toBe(1) + expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') + // The persisted chunk events are the authoritative incrementality proof. + expect(sessionEvents.filter(e => e.type === 'assistant/chunk').length).toBeGreaterThan(10) + }, 60_000) + + it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-aria')) + // Anchor assertions survive a semantics-preserving component rewrite even + // while the whole-region golden churns. + await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true) + expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think')) + // Interaction over the REAL wire-delivered transcript (the fixture-client + // tier pins the same gesture against FixtureApiClient; this one runs on + // mux-frame-fed state). Runs after the golden capture so the committed + // aria surface stays the untouched settled state. + const think = page.getByRole('button', { name: /^Think/ }).first() + expect(await think.getAttribute('aria-expanded')).toBe('false') + await think.click() + await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') + await think.click() + await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') + }) + + it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts new file mode 100644 index 0000000000..babfbde919 --- /dev/null +++ b/apps/web/tests/scaffold.ts @@ -0,0 +1,440 @@ +// Shared scaffold for the keyless browser e2e lane (Agent Note: +// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). +// Boots the REAL web composition — the shipped apps/cli/cordis.yml through +// the vendored Loader (the same include boot AppCLIEntry drives), patched the +// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the +// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: +// replay (default, keyless: llm-deepseek row disabled, dsh-llm-replay row +// inserted in providers mode), record (real adapter + key, harvests fixtures +// from live session memory), refresh (keyless replay that rewrites goldens). +// +// Composition divergences from `dsh web`, all deliberate, all via include +// patches over the SAME tree (never a second yml): temp persistenceRoot; +// workspace-context disabled (recorded fixtures must not embed this repo's +// AGENTS.md); session-title-llm disabled (its fire-and-forget title call +// would race the loop for the session's replay cursor); webserver pinned to +// port 0 with the built dist; keyless modes disable llm-deepseek and fill +// the open llm seam post-boot with installLlmReplay on the settled root ctx +// (the plugin-row path discards the ReplayHandle; the direct install keeps +// assertConsumed for the teardown fixture-consumption check). +import { existsSync, readFileSync } from 'node:fs' +import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { Page } from 'playwright' +import { expect } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' +import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot' +import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' +import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +// Empty type imports carry the httpServer/agents/sessionPersistence Context merges. +import type {} from '@deepseek-ai/dsh-host-webserver' +import type {} from '@deepseek-ai/dsh-agent' +import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' + +/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */ +export type WebSnapshotMode = 'replay' | 'record' | 'refresh' + +/** + * Resolve and validate the lane's snapshot mode. + * @returns the active mode; unset/empty selects replay. + */ +export function webSnapshotMode(): WebSnapshotMode { + const value = process.env.DSH_SNAPSHOT + if (value === undefined || value === '' || value === 'replay') return 'replay' + if (value === 'record' || value === 'refresh') return value + throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`) +} + +/** The shipped composition under test: apps/cli's config tree. */ +const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml') + +// Replay publishes the provider catalog the gateway routes to (providers +// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a +// catch-all would leave resolveModelContext unroutable and compact-basic's +// post-step pressure check would warn every step). The published +// contextWindow keeps that pressure path provably inert for small fixtures. +const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] + +/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */ +function loadRootEnv(): void { + const envPath = join(REPO_ROOT, '.env') + if (!existsSync(envPath)) return + for (const line of readFileSync(envPath, 'utf8').split('\n')) { + const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim()) + if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2] + } +} + +/** A booted web scaffold: real composition, mode-selected model backend, temp world. */ +export interface WebScaffold { + /** The active snapshot mode this scaffold booted under. */ + mode: WebSnapshotMode + /** Browser-facing origin (http://127.0.0.1:). */ + baseUrl: string + /** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */ + ctx: Context + /** Temp project directory sessions run in (bash/fs tool cwd). */ + workspaceCwd: string + /** Temp persistence root (seeded sessions land here through the real API). */ + persistenceRoot: string + /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */ + whenTurnSettled(timeoutMs?: number): Promise + /** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */ + close(): Promise +} + +/** Options for {@link launchWebScaffold}. */ +export interface LaunchOptions { + /** + * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row + * in replay/refresh modes; ignored in record mode (the real adapter + * answers). Omit for scenarios issuing no model calls — a stray stream then + * fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row + * mounts). + */ + replayFixture?: string + /** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */ + paceMs?: number +} + +/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ +async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persistenceRoot: string): Promise { + const failures: unknown[] = [] + await Promise.resolve(ctx.fiber.dispose()).catch((error: unknown) => failures.push(error)) + await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + return failures +} + +/** + * Boot the real web composition under the current snapshot mode. + * @param options - replay fixture selection and pacing. + * @returns the running scaffold. + */ +export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + requireDist() + const mode = webSnapshotMode() + if (mode === 'record') { + loadRootEnv() + if (process.env.DEEPSEEK_API_KEY === undefined || process.env.DEEPSEEK_API_KEY.length === 0) { + throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)') + } + } + const workspaceCwd = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')) + let persistenceRoot: string + try { + persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-')) + } catch (error) { + const failures: unknown[] = [error] + await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError)) + if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed') + throw error + } + + // The include patch set — the same mechanism AppCLIEntry and the ACP + // snapshot overlay use, applied over the SAME shipped tree (a patch id that + // stops matching a row fails the boot sweep loudly instead of drifting). + const patches: PatchOptions[] = [ + { id: 'session-persistence-jsonl', config: { root: persistenceRoot } }, + // storage-json's './.storages' yml default is cwd-relative and resolves + // per write; the scaffold restores the original cwd after boot, so the + // row gets an absolute temp root (removed with the workspace at close). + { id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } }, + // fs/bash cwd default to process.cwd(); the gateway injects the same + // value into session.cwd — chdir below anchors all three to the temp + // workspace, keeping the composition untouched. + { id: 'workspace-context', disabled: true }, + { id: 'session-title-llm', disabled: true }, + { id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } }, + ...mode === 'record' ? [] : [{ id: 'llm-deepseek', disabled: true }], + ] + + // Sessions inherit the gateway's process.cwd() default; run the boot from + // the temp workspace so tool cwd, session cwd, and fixtures agree. + const originalCwd = process.cwd() + const ctx = new Context() + let port = 0 + let replayHandle: ReplayHandle | undefined + try { + process.chdir(workspaceCwd) + ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches }, + }) + await ctx.loader.await() + assertEntriesLoaded(ctx, 'web e2e scaffold') + const boundPort = ctx.get('httpServer')?.port + if (boundPort === undefined) { + throw new Error('web e2e scaffold: httpServer service missing after settled boot') + } + port = boundPort + + // Fill the open llm seam on the settled root ctx (llm-deepseek is disabled + // in keyless modes; a scenario with no fixture leaves the seam empty so a + // stray stream fails loud with NO_ADAPTER). The direct install, unlike the + // plugin row, returns the ReplayHandle for the teardown consumption check. + if (mode !== 'record' && options.replayFixture !== undefined) { + replayHandle = installLlmReplay(ctx, { + file: options.replayFixture, + providers: REPLAY_PROVIDERS, + ...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }), + }) + } + } catch (error) { + if (process.cwd() !== originalCwd) process.chdir(originalCwd) + const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot) + if (cleanupFailures.length > 0) { + throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete') + } + throw error + } finally { + if (process.cwd() !== originalCwd) process.chdir(originalCwd) + } + + return { + mode, + baseUrl: `http://127.0.0.1:${port}`, + ctx, + workspaceCwd, + persistenceRoot, + // Barrier stack: the in-process turn/end identifies the session, then + // agent.whenIdle() covers the persistence flush (the idle flip follows + // the flush), and the caller's browser settled-poll comes last because + // host completion strictly precedes render. + whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise { + return new Promise((resolveSettled, reject) => { + const timer = setTimeout(() => { + off() + reject(new Error(`no turn/end within ${timeoutMs}ms`)) + }, timeoutMs) + const off = ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + if (event.type !== 'turn/end') return + clearTimeout(timer) + off() + const agent = ctx.agents.get(session.id) + if (agent === undefined) { + reject(new Error(`turn/end for ${session.id} but no live agent`)) + return + } + agent.whenIdle().then(() => { resolveSettled(session.id) }, reject) + }) + }) + }, + async close(): Promise { + const failures: unknown[] = [] + // Fixture-consumption check first, while the run's binding state is + // still authoritative — a scenario that drove fewer model calls than + // recorded fails here instead of drifting green. + try { + replayHandle?.assertConsumed() + } catch (error) { + failures.push(error) + } + failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)) + if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed') + }, + } +} + +/** + * Serialize a live session back to raw session-JSONL (header + events) — the + * in-memory record-mode harvest, so the on-disk zstd default never matters. + * Mirrors the TUI suite's rawSessionLog. + */ +function rawSessionLog(session: Session): string { + return [ + JSON.stringify({ type: 'session', ...session.header }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +/** + * Record-mode fixture write-back: harvest the live session, scrub request + * headers to {{system}}/{{tools}} (TODO(web-header-pin): the web lane pins no + * header class — a deliberate deviation logged in the Agent Note's deferred + * work), tokenize the run-local session id, cwd, and browser RPC id + * ({{sessionId}}/{{cwd}}/{{rpcId}}, the committed fixture convention — + * re-records then diff only on real content), and write the fixture. + * @param scaffold - the record-mode scaffold. + * @param sessionId - the driven session. + * @param fixturePath - the committed session.jsonl / seed.jsonl target. + */ +export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise { + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`) + const tokenized = scrubRequestHeaders(rawSessionLog(agent.session)) + .split(sessionId).join('{{sessionId}}') + .split(scaffold.workspaceCwd).join('{{cwd}}') + .replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"') + await writeFile(fixturePath, tokenized) +} + +/** + * The user prompts recorded in a fixture, in order — the single source tying + * spec drive steps to recorded reality so script and fixture cannot drift. + * @param fixtureText - raw session.jsonl contents. + * @returns the recorded user prompt texts. + */ +export function fixtureUserPrompts(fixtureText: string): string[] { + return parseSessionLog(fixtureText).flatMap((event) => { + if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] + const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('') + return text.length > 0 ? [text] : [] + }) +} + +/** + * Seed a recorded session fixture into the scaffold's persistence root + * through the REAL backend API (throwaway Context + SessionStore + JSONL + * plugin — the semantic-checkpoint precedent), never raw file writes: no + * knowledge of bucket hashing, filename encoding, or compression, and + * malformed shapes fail loud at seed time. The fixture's tokenized identity + * ({{sessionId}}/{{cwd}}) is realized for this world before parsing. + * @param scaffold - the target scaffold. + * @param fixtureText - raw recorded session.jsonl contents. + * @param id - the seeded session id (stable for deterministic goldens). + * @returns the seeded id. + */ +export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise { + const realized = fixtureText + .split('{{sessionId}}').join(id) + .split('{{cwd}}').join(scaffold.workspaceCwd) + const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd + const rewritten = fixtureCwd === undefined + ? realized + : realized.split(fixtureCwd).join(scaffold.workspaceCwd) + const events = parseSessionLog(rewritten) + if (events.length === 0) throw new Error('seed fixture has no events') + const last = events[events.length - 1]! + // An open final turn would be mutated by resume's crash repair on first + // open; a committed seed must be a closed recording. + if (last.type !== 'turn/end') throw new Error(`seed fixture must end in turn/end, got ${last.type}`) + const meta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: SessionId(id), + createdAt: Date.now() - 60_000, + cwd: scaffold.workspaceCwd, + delegationDepth: 0, + } + const seeder = new Context() + try { + await seeder.plugin(SessionStore) + // Same root as the booted tree with the plugin's own default compression, + // so the host's directory-scan list() sees one consistent encoding. + await seeder.plugin(SessionPersistenceJsonl, { root: scaffold.persistenceRoot }) + await seeder.sessionPersistence.create(meta) + await seeder.sessionPersistence.append(meta.id, events) + // Deterministic sidebar order: cold summaries take updatedAt from mtime. + const located = seeder.sessionPersistence.locate(meta) + if (located !== undefined) { + const backdated = new Date(meta.createdAt) + await utimes(located.path, backdated, backdated) + } + } finally { + await seeder.fiber.dispose() + } + return meta.id +} + +/** + * Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration + * volatility collapse to stable tokens. + */ +function normalizeAria(snapshot: string, workspaceCwd: string): string { + // The header breadcrumb renders the workspace's basename, not the full + // path, so both spellings must collapse to the token. + const base = workspaceCwd.split('/').pop()! + return snapshot + .split(workspaceCwd).join('{{cwd}}') + .split(base).join('{{workspace}}') + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') + .replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}') +} + +/** + * Capture the region's aria snapshot at a settled milestone: poll until two + * consecutive normalized captures are equal — a single-shot capture races the + * last React commits. + * @param page - the page under test. + * @param selector - the region locator selector. + * @param workspaceCwd - normalization input. + * @returns the stable normalized snapshot. + */ +export async function captureStableAria(page: Page, selector: string, workspaceCwd: string): Promise { + const region = page.locator(selector).first() + let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd) + await expect.poll(async () => { + const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd) + const stable = current === previous + previous = current + return stable + }, { timeout: 5_000, message: 'aria snapshot did not stabilize' }).toBe(true) + return previous +} + +/** + * Compare a normalized golden, or rewrite it under refresh. Refresh is the + * ONLY writer: a missing golden in replay mode fails with the healing command + * instead of silently self-bootstrapping. + * @param goldenPath - the committed ui.expected.md path. + * @param actual - the stable normalized snapshot. + * @param mode - the active snapshot mode. + */ +export async function compareOrRefreshGolden(goldenPath: string, actual: string, mode: WebSnapshotMode): Promise { + const payload = `${actual}\n` + if (mode === 'refresh') { + await writeFile(goldenPath, payload) + return + } + if (!existsSync(goldenPath)) { + throw new Error(`missing golden ${goldenPath} — run DSH_SNAPSHOT=refresh pnpm run test:web to generate it`) + } + expect(payload).toBe(await readFile(goldenPath, 'utf8')) +} + +/** + * Fixture-inventory guard (the TUI afterAll shape): the scenario directory + * holds exactly the expected files and every committed JSONL is a scrub + * fixed-point without a run-local browser RPC id. + * @param dir - the scenario snapshot directory. + * @param expected - the exact expected file inventory. + */ +export async function assertFixtureInventory(dir: string, expected: string[]): Promise { + const entries = (await readdir(dir)).sort() + expect(entries).toEqual([...expected].sort()) + for (const entry of entries.filter(name => name.endsWith('.jsonl'))) { + const content = await readFile(join(dir, entry), 'utf8') + expect(scrubRequestHeaders(content), `${dir}/${entry} carries request-header bulk`).toBe(content) + expect(content, `${dir}/${entry} carries a run-local rpcId`) + .not.toMatch(/"rpcId":"(?!\{\{rpcId\}\})[^"]+"/) + } +} + +/** + * Console tripwires: reconnect/gap-repair self-healing or a pageerror must + * fail the scenario, not mask a dead wire behind eventual consistency. + * @param page - the page under test. + * @returns live warning/pageerror collectors to assert empty at scenario end. + */ +export function watchConsole(page: Page): { warnings: string[]; pageErrors: string[] } { + const warnings: string[] = [] + const pageErrors: string[] = [] + page.on('console', (message) => { + const text = message.text() + if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text) + }) + page.on('pageerror', (error) => { pageErrors.push(String(error)) }) + return { warnings, pageErrors } +} diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts new file mode 100644 index 0000000000..97ebfff0b1 --- /dev/null +++ b/apps/web/tests/seeded-history.e2e.ts @@ -0,0 +1,124 @@ +// Web e2e scenario: seeded history. A recorded session seeded cold through +// the REAL persistence API renders purely from the log — the surface nothing +// else covers: sidebar cold listing, the implicit resume/attach inside the +// history RPC, history-page tool views, and the client fold of historical +// events — with ZERO model calls in replay (no replay fixture; a stray stream +// fails loud on the open llm seam). The seed is a recorded fixture under the +// same record discipline as every other: DSH_SNAPSHOT=record drives the turn +// live through the composer (real read tool against seeded workspace files) +// and harvests seed.jsonl; replay/refresh seed it cold and only render. +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { join } from 'node:path' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url)) +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'seeded-history-web-e2e' + +const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' + +describe('web e2e: seeded history renders through cold resume', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // The workspace-aware flow runs sessions in /workspace + // (the 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]) + await seedSession(scaffold, raw, SEED_ID) + } + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE !== 'record')('records the seed turn live through the composer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-record')) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + await recordFixture(scaffold, sessionId, SEED) + }, 200_000) + + it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-history')) + // The sidebar tree collapses workspace groups by default: click the group + // row (treeitem 0) to expand, then the revealed session row. + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + // Settled barrier for history: the recorded final assistant text renders. + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + // Tool cards render from logged tool/call + tool/result alone (views are + // host-recomputed per page; the generic card is the documented default). + const toolRows = page.locator('[data-variant], [data-sample]') + await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0) + }, 60_000) + + it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria')) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow')) + // Interaction over cold-resumed history: read rows are expand-in-place + // rows (rowExpands routes the click to toggleExpand, not openDetails), so + // the gesture under test is the inline fold over log-rebuilt content. + // Runs after the golden capture; still zero model calls. + const row = page.locator('[data-variant] [data-clickable][role="button"]').first() + await row.waitFor({ timeout: 10_000 }) + expect(await row.getAttribute('aria-expanded')).toBe('false') + await row.click() + await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') + // The expanded body renders the recorded tool result (a.txt's contents). + await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) + await row.click() + await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { + // No replay fixture was installed and the llm seam is open — any stray + // stream would have failed the turn loudly. Cleanliness pins the wire. + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md']) + }) +}) 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/snapshots/fresh-round-trip/session.jsonl b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl new file mode 100644 index 0000000000..21218b459d --- /dev/null +++ b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl @@ -0,0 +1,95 @@ +{"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":"{{rpcId}}"}}}} +{"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":"{{rpcId}}"}},"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 new file mode 100644 index 0000000000..a6d1203d9d --- /dev/null +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -0,0 +1,31 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the bash tool to" [disabled] + - text: · 1 turns + - tablist: + - tab "Chat" [selected] + - 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 simple bash command and reply with \"DONE\".": + - img + - 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 executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". +- paragraph: DONE +- 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] + - option "Agent" +- combobox "Access mode": + - option "Read-only" [selected] + - option "Read-write" +- combobox "Model": + - option "DeepSeek-V4-Pro High" [selected] + - option "DeepSeek-V4-Pro" +- button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/seeded-history/seed.jsonl b/apps/web/tests/snapshots/seeded-history/seed.jsonl new file mode 100644 index 0000000000..27e31004bc --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/seed.jsonl @@ -0,0 +1,112 @@ +{"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":"{{rpcId}}"}}}} +{"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":"{{rpcId}}"}},"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 new file mode 100644 index 0000000000..c919fccec1 --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -0,0 +1,36 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the read tool twice" [disabled] + - text: · 1 turns + - tablist: + - tab "Chat" [selected] + - 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 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 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 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 now reply with DONE as instructed. +- paragraph: DONE +- 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] + - option "Agent" +- combobox "Access mode": + - option "Read-only" [selected] + - option "Read-write" +- combobox "Model": + - option "DeepSeek-V4-Pro High" [selected] + - option "DeepSeek-V4-Pro" +- button "Send message" [disabled] diff --git a/apps/web/tests/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/apps/web/tsconfig.json b/apps/web/tsconfig.json index d0af0d4641..54c5673451 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -17,6 +17,15 @@ "src", "tests" ], + // The web e2e lane (scaffold + replay specs) boots the host spine and reads + // its Context merges — host-plane programs, checked in tsconfig.host.json; + // this client-registered project must not also hold them (one program + // cannot see both sides of the cordis Context merges). + "exclude": [ + "tests/scaffold.ts", + "tests/replay-round-trip.e2e.ts", + "tests/seeded-history.e2e.ts" + ], "references": [ { "path": "../../packages/client/web" diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index af1c5a95ef..e7b4f7f3ab 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 -architecture.md: 7599c48dc444b9b61ca1ffff37a9ec0751de2c14 -architecture.zh.md: b8d8c38fc72152b2bc20b4d3a11883e6a024ac16 +architecture.md: abdd1eda213a684f9745413b82aeefd4a6c296d3 +architecture.zh.md: 88088d3f76f161f207eb7ba53df943332c32c3c5 diff --git a/docs/architecture.md b/docs/architecture.md index 7599c48dc4..abdd1eda21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` interface: concrete live-preferred exact/filter/trace; exactly two abstract FTS methods via `session-query-sqlite` | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | Live-preferred exact/filter/trace interface, SQLite FTS backend, and workspace-authorized model tools | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks plus one optional asynchronous provider | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry for package-owned runtime checks | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b8d8c38fc7..88088d3f76 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -43,7 +43,7 @@ | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` 接口:精确检索、过滤与追踪采用实时优先的具体实现;恰有两个全文搜索方法为抽象方法,由 `session-query-sqlite` 实现 | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端,以及经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题,以及单个可选的异步提供方 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6363add08e..0f2dfd1610 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,10 +40,13 @@ 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"] + pkg_tool_session_query["tool-session-query"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] pkg_tui["tui"] pkg_session_title["session-title"] @@ -180,6 +183,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 @@ -241,6 +245,7 @@ flowchart LR svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash svc_sessionQuery --> pkg_session_reference + svc_sessionQuery --> pkg_tool_session_query svc_sessionReferences --> pkg_tui svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop @@ -253,7 +258,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 +287,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,9 +299,10 @@ 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.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.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), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `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. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e9dfe554be..2aa2530f87 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -57,7 +57,7 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory for JSONL sessions. Defaults to `./.sessions`. */ + /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ persistenceRoot?: string /** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */ packChunks?: boolean @@ -78,7 +78,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:37`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:39`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -482,19 +482,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` @@ -660,6 +662,8 @@ export interface Config { childFiles?: string[] /** Optional replay-only provider catalog; absent or empty selects catch-all waterfall replay. */ providers?: ReplayProviderConfig[] + /** Optional per-chunk pacing delay in ms (see {@link ReplayConfig.paceMs}); absent keeps burst yield. */ + paceMs?: number } /** One provider route exposed by the replay adapter. */ @@ -689,7 +693,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:411`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:478`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -1056,6 +1060,8 @@ export interface Config extends SessionQueryConfig { maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number + /** Maximum concurrent persisted-log inspections in one inherited batch read. Defaults to 4. */ + persistedInspectConcurrency?: number } /** Supported SQLite journal modes. */ @@ -1064,7 +1070,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:74`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` @@ -1211,7 +1217,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` @@ -1538,6 +1544,22 @@ export interface Config { Source: [`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/tool-ralph/src/index.ts) +## `@deepseek-ai/dsh-tool-session-query` + +Requires: `tools` · `systemPrompt` · `sessionQuery` + +```ts config-catalog +/** Deployment-owned search count and timeout bounds. */ +export interface Config { + /** Maximum authorized hits returned by one search call. Defaults to 100. */ + maxSearchResults?: number + /** Cooperative full-text search deadline in milliseconds. Defaults to 30000. */ + searchTimeoutMs?: number +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../packages/session-query/tool-session-query/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` @@ -1780,8 +1802,8 @@ export interface Config { /** * Shell command template the TUI prints on exit and lists under `/resume`, * with `{session}` replaced by the live session id (forwarded to the front - * door). Set it to a command that resumes via this app's env var, e.g. - * `RESUME_SESSION_ID={session} dsh`. + * door). Set it to a command that resumes the session, e.g. + * `dsh --resume {session}`. */ resumeCommand?: string /** Full-screen TUI presentation settings. */ @@ -2018,6 +2040,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)) @@ -2034,7 +2057,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) @@ -2065,8 +2088,8 @@ 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-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/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)) - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3a33ab0e7e..0491430662 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1016,15 +1016,17 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven * This read is serialized with writes for the same id and returns detached * values, so observers cannot mutate backend-owned state. * @param id - the persisted session to inspect. + * @param signal - optional cancellation for queued and backend read work. * @returns the header and valid stored event prefix exactly as observed. */ -abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> /** * Lightweight listing from metadata, without a full-log parse. + * @param signal - optional cancellation for backend listing work. * @returns one header per materialized session. */ -abstract list(): Promise +abstract list(signal?: AbortSignal): Promise /** * List materialized sessions with cheap per-log change tokens. @@ -1033,9 +1035,10 @@ abstract list(): Promise * successful mutating {@link load} repair changes the next listed revision. * Revisions also distinguish independently backed stores so backend-local * counters cannot compare equal across different persistence sources. + * @param signal - optional cancellation for backend snapshot-listing work. * @returns one header and opaque revision per materialized session without loading full logs. */ -abstract listSnapshots(): Promise +abstract listSnapshots(signal?: AbortSignal): Promise ``` Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) @@ -1061,15 +1064,16 @@ abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExec * Search events within one live-preferred logical session. * @param request - target session, query text, filters, page size, and cursor. * @param exec - optional cancellation control. - * @returns matching event hits in deterministic relevance order. + * @returns matching event hits and their target header from one indexed generation. */ -abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> +abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise /** * List the complete logical corpus using live-preferred records. + * @param signal - optional cancellation for persistence listing. * @returns deterministic newest-first cloned session records. */ -listSessions(): Promise +listSessions(signal?: AbortSignal): Promise /** * Read and replay-validate one complete logical session log without making it live. @@ -1082,16 +1086,37 @@ async readSession(sessionId: SessionId): Promise /** * Filter the complete logical corpus with provider-independent predicates. * @param filters - ANDed session metadata and availability clauses. + * @param signal - optional cancellation for persistence listing. * @returns matching cloned records in deterministic newest-first order. */ -async filterSessions(filters: readonly SessionResultFilter[]): Promise +async filterSessions( filters: readonly SessionResultFilter[], signal?: AbortSignal, ): Promise /** * Fold the latest log-backed title from one live-preferred logical session. * @param sessionId - live or persisted session id to read. + * @param signal - optional cancellation for source resolution and title folding. * @returns latest title snapshot, or `undefined` when the log has no title event. */ -async readTitle(sessionId: SessionId): Promise +async readTitle( sessionId: SessionId, signal?: AbortSignal, ): Promise + +/** + * Fold the latest title and return its source header from one corpus observation. + * @param sessionId - live or persisted session id to read. + * @param signal - optional cancellation for source resolution and title folding. + * @returns cloned source header and optional latest title snapshot. + */ +async readTitleSnapshot( sessionId: SessionId, signal?: AbortSignal, ): Promise + +/** + * Fold titles for unique sessions from one cancellable corpus observation. + * + * Results preserve first-occurrence input order. Operational failures stay + * isolated per session, while cancellation rejects the complete operation. + * @param sessionIds - live or persisted session ids to observe. + * @param signal - optional cancellation shared by all source reads. + * @returns one fulfilled or rejected result per unique requested id. + */ +async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise /** * List lightweight raw-log event records for one logical session. @@ -1119,30 +1144,33 @@ async readSurface(sessionId: SessionId): Promise /** * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. + * @param signal - optional cancellation for persistence listing. * @returns a complete lineage or an explicit unresolved parent boundary. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ -async traceSession(sessionId: SessionId): Promise +async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise /** * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. - * @returns direct links plus the target's positional replacement chain. + * @param signal - optional cancellation for persisted source resolution. + * @returns source header, direct links, and the target's positional replacement chain. * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ -async traceEvent(request: SessionEventTraceRequest): Promise +async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise /** * Read one full event plus a bounded raw-log context window. * @param request - target session/seq and context sizes. + * @param signal - optional cancellation for persisted source resolution. * @returns cloned target and neighboring events. */ -async readEvent(request: SessionEventReadRequest): Promise +async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:74`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:81`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` @@ -1450,7 +1478,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` @@ -1914,49 +1985,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/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 4ea02cbc2c..ebb8bfb6f3 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: b03cc07d2e514b3900d4035ea386f31c761470a7 +persistence.md: 4ec967873e946c8f185f8a8f497f2af4a363474e persistence.zh.md: 3030ff2fe949cb02385331800d826df227e3d6cd diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index b03cc07d2e..4ec967873e 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -128,7 +128,7 @@ interface SessionPersistenceSnapshot { ## The backends -Both implement the same abstract `SessionPersistence` (locate/create/append/load/inspect/list/listSnapshots over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (locate/create/append/load/inspect/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index 2cba0f9b42..f9c7355148 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.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 -session-query.md: c6dde8714a0875d46cf7b49cc181daab8f44afe0 -session-query.zh.md: 44be8b7f89e1576e54c8dc3cb60619d6728c6895 +session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e +session-query.zh.md: ecf330b0a361ffae352a91c0d35524444936606d diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index c6dde8714a..d92af4bac3 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -51,6 +51,39 @@ interface SessionSurfaceSnapshot { } ``` +`SessionTitleObservation` applies the same atomic-observation rule to title folding, so an authorization consumer can validate the source header that supplied the title. Batch reads return one ordered `SessionTitleObservationResult` per unique requested id: operational failures remain local to that id, while cancellation rejects the complete operation. + +```ts type-equiv +/** Latest folded title bound to the same session-header observation. */ +interface SessionTitleObservation { + /** Cloned header selected with the event log used for the title fold. */ + session: SessionHeader + /** Latest title snapshot, absent when the observed log has no title. */ + title?: SessionTitleSnapshot +} +``` + +```ts type-equiv +/** One ordered result from a batch title observation. */ +type SessionTitleObservationResult = + | { + /** Requested session id. */ + sessionId: SessionId + /** Successful atomic header/title observation. */ + status: 'fulfilled' + /** Header and optional latest title from one logical source. */ + value: SessionTitleObservation + } + | { + /** Requested session id. */ + sessionId: SessionId + /** Operational failure isolated to this session. */ + status: 'rejected' + /** Original failure from logical-source resolution or title folding. */ + reason: unknown + } +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { @@ -158,6 +191,16 @@ interface SessionSearchPage { } ``` +Unlike grouped cross-session hits, a within-session search must also expose its observed target header even when the page contains no hits. + +```ts type-equiv +/** Event-search results bound to the indexed target-session observation. */ +interface SessionEventSearchPage extends SessionSearchPage { + /** Cloned target header from the same indexed generation as `items`. */ + session: SessionHeader +} +``` + ```ts type-equiv /** One event full-text search hit with a bounded plain-text excerpt. */ interface SessionEventSearchHit extends SessionEventRecord { @@ -279,6 +322,14 @@ interface SessionEventTrace { } ``` +```ts type-equiv +/** Event relationships bound to the same session-header observation. */ +interface SessionEventTraceObservation extends SessionEventTrace { + /** Cloned header selected with the event log used for the trace. */ + session: SessionHeader +} +``` + ## Errors The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 44be8b7f89..ecf330b0a3 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -51,6 +51,39 @@ interface SessionSurfaceSnapshot { } ``` +`SessionTitleObservation` 将同样的原子观测规则应用于标题折叠,使授权消费者能够验证提供标题的源 header。批量读取会按顺序为每个唯一请求 id 返回一个 `SessionTitleObservationResult`:操作失败只影响对应 id,而取消会拒绝整个操作。 + +```ts type-equiv +/** Latest folded title bound to the same session-header observation. */ +interface SessionTitleObservation { + /** Cloned header selected with the event log used for the title fold. */ + session: SessionHeader + /** Latest title snapshot, absent when the observed log has no title. */ + title?: SessionTitleSnapshot +} +``` + +```ts type-equiv +/** One ordered result from a batch title observation. */ +type SessionTitleObservationResult = + | { + /** Requested session id. */ + sessionId: SessionId + /** Successful atomic header/title observation. */ + status: 'fulfilled' + /** Header and optional latest title from one logical source. */ + value: SessionTitleObservation + } + | { + /** Requested session id. */ + sessionId: SessionId + /** Operational failure isolated to this session. */ + status: 'rejected' + /** Original failure from logical-source resolution or title folding. */ + reason: unknown + } +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { @@ -158,6 +191,16 @@ interface SessionSearchPage { } ``` +与跨会话分组 hit 不同,会话内搜索即使没有命中项,也必须公开它观测到的目标 header。 + +```ts type-equiv +/** Event-search results bound to the indexed target-session observation. */ +interface SessionEventSearchPage extends SessionSearchPage { + /** Cloned target header from the same indexed generation as `items`. */ + session: SessionHeader +} +``` + ```ts type-equiv /** One event full-text search hit with a bounded plain-text excerpt. */ interface SessionEventSearchHit extends SessionEventRecord { @@ -279,6 +322,14 @@ interface SessionEventTrace { } ``` +```ts type-equiv +/** Event relationships bound to the same session-header observation. */ +interface SessionEventTraceObservation extends SessionEventTrace { + /** Cloned header selected with the event log used for the trace. */ + session: SessionHeader +} +``` + ## 错误 封闭的 code 联合类型区分请求校验、目标缺失、surface 日志格式错误、可选后端故障与矛盾的源元数据。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 804f3bdca4..27a534e975 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:487`](../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:55`](../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 5969b24436..cabc925e41 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -106,6 +106,7 @@ flowchart TD subgraph group_session_query["packages/session-query"] pkg_session_query["session-query"] pkg_session_query_sqlite["session-query-sqlite"] + pkg_tool_session_query["tool-session-query"] end subgraph group_session_title["packages/session-title"] pkg_session_title["session-title"] @@ -117,6 +118,7 @@ flowchart TD pkg_acp_snapshot["acp-snapshot"] pkg_agent_loop_testkit["agent-loop-testkit"] pkg_invariants["invariants"] + pkg_llm_mock_server["llm-mock-server"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] end @@ -147,6 +149,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 +174,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"] @@ -224,6 +226,7 @@ flowchart TD pkg_skill --> pkg_invariants pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants + pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants pkg_client_i18n --> pkg_invariants pkg_client_modules --> pkg_invariants @@ -238,7 +241,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 @@ -260,6 +262,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 @@ -601,6 +607,13 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools + pkg_tool_session_query --> pkg_invariants + pkg_tool_session_query --> pkg_llm + pkg_tool_session_query --> pkg_session + pkg_tool_session_query --> pkg_session_query + pkg_tool_session_query --> pkg_system_prompt + pkg_tool_session_query --> pkg_timeout + pkg_tool_session_query --> pkg_tools pkg_agent_loop_testkit --> pkg_agent pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm @@ -751,6 +764,8 @@ flowchart TD pkg_acp_demo --> pkg_invariants pkg_acp_demo --> pkg_session_checkpoint_policy pkg_acp_demo --> pkg_session_persistence_jsonl + pkg_acp_demo --> pkg_session_query + pkg_acp_demo --> pkg_session_query_sqlite pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_workspace_context pkg_cli_demo --> pkg_agent @@ -766,7 +781,6 @@ flowchart TD pkg_tui_demo --> pkg_agent pkg_tui_demo --> pkg_agent_loop pkg_tui_demo --> pkg_agent_spine_demo - pkg_tui_demo --> pkg_app_boot pkg_tui_demo --> pkg_command_goal pkg_tui_demo --> pkg_commands pkg_tui_demo --> pkg_invariants @@ -795,6 +809,7 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | +| [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | @@ -809,7 +824,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), [`timeout`](../packages/util/timeout) | @@ -818,6 +832,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) | @@ -898,6 +913,7 @@ flowchart TD | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | `session-persistence` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | @@ -918,6 +934,6 @@ flowchart TD | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index ecb6ee40c0..cf383d4da7 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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 -testing.md: ac5324c6ad2cc0718d9ad4f2d699120abb033374 -testing.zh.md: 54e7a91b69480893a871d73b95696b10ffd69926 +testing.md: 678d2e218590f70e6424a60286e46db87cf278cc +testing.zh.md: 776f09bfe534f8460efda59623dc8f139cd43fe7 diff --git a/docs/testing.md b/docs/testing.md index ac5324c6ad..678d2e2185 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,6 +10,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Web browser snapshot** (gate-exempt `pnpm run test:web`): real chromium over the in-process web composition replays recorded fixtures against conversation aria goldens (`apps/web/tests/snapshots/`); record/refresh semantics and the deferred CI browser decision: [web e2e lane Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). ## The with-key policy: inference is cheap here @@ -43,4 +44,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 54e7a91b69..776f09bfe5 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -10,6 +10,7 @@ - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 - **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **Web 浏览器快照**(豁免门禁的 `pnpm run test:web`):真实 chromium 在进程内 web 组装之上回放已录制 fixture,与会话区 aria 预期输出比对(`apps/web/tests/snapshots/`);`DSH_SNAPSHOT=record`/`refresh` 的语义与暂缓的 CI 浏览器决策见 [web e2e 车道 Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)。 ## 带密钥策略:推理在这里很便宜 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 88ae192ef0..3cdc822a7a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -27,6 +27,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | @@ -778,6 +779,239 @@ Load the full instructions for an available skill. Call this with the exact skil Source: [`packages/skill/tool-skill/src/index.ts`](../packages/skill/tool-skill/src/index.ts) +## `@deepseek-ai/dsh-tool-session-query` + +### `session_event_read` + +Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. + +```json +{ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +### `session_event_search` + +Search prior events in one authorized session; the current session excludes the step performing this call. + +```json +{ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +### `session_event_trace` + +Read every direct replacement and provenance relationship for one event in an authorized session. + +```json +{ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +### `session_search` + +Search prior sessions in the caller workspace and return the strongest matching event from each session. + +```json +{ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +### `session_trace` + +Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. + +```json +{ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. + ## `@deepseek-ai/dsh-tool-subagent` ### `subagent` diff --git a/examples/README.md b/examples/README.md index 8630f235b4..f1f3967634 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the `demo:*` scripts invoke each app package's bin. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI (which mounts the `tui-demo` bundle), and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. ## headless-agent diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 589bde267d..892a32cdc7 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -7,7 +7,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport ``` -The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, model-facing tools, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`fs.cordis.yml`](fs.cordis.yml) adds local tool-result spill storage for dedicated scenarios; [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. +The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, while [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. ## Protocol channel diff --git a/examples/acp-agent/session-query.cordis.snapshot.yml b/examples/acp-agent/session-query.cordis.snapshot.yml new file mode 100644 index 0000000000..1edadf8374 --- /dev/null +++ b/examples/acp-agent/session-query.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# Keyless counterpart to session-query.cordis.yml: the nested snapshot overlay +# supplies replay plus deterministic private spill storage and its byte limit. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./fs.cordis.snapshot.yml + patches: + - insert: + - id: tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' diff --git a/examples/acp-agent/session-query.cordis.yml b/examples/acp-agent/session-query.cordis.yml new file mode 100644 index 0000000000..e5e45025df --- /dev/null +++ b/examples/acp-agent/session-query.cordis.yml @@ -0,0 +1,12 @@ +# Explicit session-query tool opt-in for the dedicated spill scenario. The +# nested filesystem overlay supplies private spill storage and its byte limit. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./fs.cordis.yml + patches: + - insert: + - id: tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 60eaa6285b..8e74711a57 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -34,6 +34,7 @@ const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url)) const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) +const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url)) 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)) @@ -88,6 +89,15 @@ const SCENARIOS: Scenario[] = [ configPath: FS_CONFIG, }, { name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG }, + { + name: 'session-query-spill', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'session-query', + configPath: SESSION_QUERY_CONFIG, + posixOnly: true, + }, { name: 'pty-tools', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 0b40bff3f9..bf8440bf80 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -129,8 +129,8 @@ {"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":128,"time":1784821261753,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} {"type":"tool/call","seq":129,"time":1784821261754,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"5b05e297-b792-4b0f-9830-d2a55eb70d7d","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"5b05e297-b792-4b0f-9830-d2a55eb70d7d","outcome":"allowed-once"}} +{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"bc159170-7ce0-4162-a6c4-ed41d4ca582f","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"bc159170-7ce0-4162-a6c4-ed41d4ca582f","outcome":"allowed-once"}} {"type":"tool/result","seq":132,"time":1784821261775,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","seq":133,"time":1784821261781,"data":{"turn":1,"step":1}} {"type":"step/start","seq":134,"time":1784821261782,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index c4475818b8..180ef6c704 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -87,8 +87,8 @@ {"type":"assistant/chunk","seq":85,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":86,"time":1784821264893,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} {"type":"tool/call","seq":87,"time":1784821264893,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"3fa5f6a5-407f-4079-bb26-8eae76e53330","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"3fa5f6a5-407f-4079-bb26-8eae76e53330","outcome":"allowed-once"}} +{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"9e3fc97b-19e4-44a1-8ff1-795683948bcd","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"9e3fc97b-19e4-44a1-8ff1-795683948bcd","outcome":"allowed-once"}} {"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}} {"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/input.json b/examples/acp-agent/tests/snapshots/session-query-spill/input.json new file mode 100644 index 0000000000..0e0abb74a2 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Read request event 4 with session_event_read, verify the complete spill was retained, then reply DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl new file mode 100644 index 0000000000..26fd2bf5c1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -0,0 +1,34 @@ +{"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":"Read request event 4 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Read request event 4 with","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":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_query_spill","name":"session_event_read","argumentsDelta":"{\"seq\":4}"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1784876318672,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36006 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_verify_session_query_spill","name":"bash","argumentsDelta":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}} +{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","content":[{"type":"text","text":"SPILL_CANONICAL_OK\n"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/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":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md new file mode 100644 index 0000000000..68bdd841c7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md @@ -0,0 +1,27 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash 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. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json new file mode 100644 index 0000000000..dde0ba0d7a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -0,0 +1,677 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index df79fd4a4e..6144d3ae4d 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -56,7 +56,7 @@ config: provider: deepseek model: deepseek-v4-pro - resumeSessionId: !!js process.env.RESUME_SESSION_ID + resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' workspaceContext: maxBytes: 65536 diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index 6e5cca3b08..c340eea036 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' import { runTuiPtySmoke } from '../../tui-agent/tests/pty-harness.ts' -const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) diff --git a/examples/package.json b/examples/package.json index bd87263340..395c135a2d 100644 --- a/examples/package.json +++ b/examples/package.json @@ -60,6 +60,7 @@ "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", + "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 2e87df0a27..5196de053b 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume ``` -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID= pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. +`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `. The TUI still prints that command on exit and shows it when a custom host cannot hand off. `dsh --resume ` provides the id on the boot context, which `cordis.yml` reads (`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`); with no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. ## Code Mode diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 49dbe62645..46d982794c 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -10,7 +10,7 @@ config: provider: deepseek model: deepseek-v4-pro - resumeSessionId: !!js process.env.RESUME_SESSION_ID + resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' resumeCommand: 'dsh --resume {session}' workspaceContext: diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 3070fcdc01..e96ef680b2 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -32,12 +32,14 @@ config: provider: deepseek model: deepseek-v4-pro - # Set RESUME_SESSION_ID to continue a prior persisted session (the ids live - # under ./.sessions); unset starts a fresh session each run. - resumeSessionId: !!js process.env.RESUME_SESSION_ID + # `dsh --resume ` provides the session id on the boot context (the ids + # live under ./.sessions); with no flag the identifier is undefined and a + # fresh session starts each run. The typeof guard tolerates a launcher that + # never provides the slot, reading undefined rather than throwing. + resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' # Printed on exit and listed by `/resume`; `{session}` fills the live id. - # `dsh --resume ` sets RESUME_SESSION_ID above, so run it from this cwd. + # `dsh --resume ` resumes that session, so run it from this cwd. resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index 4668747077..307c5a1dfe 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -29,7 +29,7 @@ # The smoke's log inspection reads plain `.jsonl`; keep the scripted # fixture uncompressed like the other snapshot-facing configs. persistenceCompression: none - resumeSessionId: !!js process.env.RESUME_SESSION_ID + resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index e55e77f4de..700c67f660 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -192,10 +192,12 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise` tree override; `configArgs` + // is the raw-args escape (e.g. `['--resume', ]`) for other flags. configArgs: options.configArgs !== undefined ? [...options.configArgs] /* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */ - : [options.configPath ?? './cordis.yml'], + : options.configPath !== undefined ? ['--config', options.configPath] : [], tsconfigPath: options.tsconfigPath, env: { DSH_HOME: join(cwd, '.dsh'), diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 43ac327d8b..348ac94751 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -8,7 +8,6 @@ import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/d import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' -const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) @@ -87,11 +86,11 @@ async function readLoggedSystemPrompt(cwd: string): Promise { throw new Error(`session log ${logRelPath} has no request/header event`) } -/** Shared defaults: the keyless key, the tui-demo bin, and the live cordis.yml. */ +/** Shared defaults: the keyless key, the dsh bin, and the live cordis.yml (passed as the positional config). */ function smoke(overrides: Partial & { label: string }): Promise { return runTuiPtySmoke({ tempDirPrefix: 'tui-agent-smoke-', - binScript, + binScript: dshBinScript, configPath, tsconfigPath, env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, @@ -246,18 +245,6 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => { - const output = await smoke({ - label: 'tui-agent resume failure', - tempDirPrefix: 'tui-agent-resume-', - env: { - DEEPSEEK_API_KEY: 'keyless-tui-no-call', - RESUME_SESSION_ID: 'missing-session', - }, - expectedExitCode: 1, - }) - expect(output).toContain('ui-tui: session "missing-session" failed to start:') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { @@ -266,7 +253,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { label: 'dsh in-place resume', tempDirPrefix: 'dsh-in-place-resume-', binScript: dshBinScript, - configArgs: [scriptedConfigPath], + configPath: scriptedConfigPath, prepare: seedResumeSession, actions: [ { waitFor: 'scripted TUI ready.', send: '/resume\r' }, @@ -341,9 +328,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => { // The flag path end to end: apps/cli parses `--resume missing-session` and - // sets RESUME_SESSION_ID, the shipped config's `!!js` reads it, and the - // resume fails loud — proving the printed `dsh --resume ` hint reaches - // the same intake as the env var. + // provides the id on the boot context, the shipped config's `!!js` reads it + // as a bare identifier, and the resume fails loud — proving the printed + // `dsh --resume ` hint reaches the config resume intake with no env var. const output = await smoke({ label: 'dsh resume flag failure', tempDirPrefix: 'dsh-resume-flag-', @@ -363,7 +350,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { label: 'dsh source-path prompt', tempDirPrefix: 'dsh-source-path-', binScript: dshBinScript, - configArgs: [scriptedConfigPath], + configPath: scriptedConfigPath, actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, diff --git a/knip.json b/knip.json index b6a1fb63d7..110abb3a2b 100644 --- a/knip.json +++ b/knip.json @@ -57,11 +57,6 @@ ] }, "packages/host/webserver": { - "project": [ - "src/**/*.ts" - ] - }, - "packages/host/runtime": { "entry": [ "tests/**/*.spec.ts" ], @@ -414,8 +409,7 @@ }, "packages/examples/tui-demo": { "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.spec.ts" ], "project": [ "src/**/*.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/package.json b/package.json index a925ea3ec9..897e81171e 100644 --- a/package.json +++ b/package.json @@ -93,9 +93,10 @@ "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml", + "demo:cordis": "node --import tsx apps/cli/src/bin.ts --config examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", + "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 5bde15dc2c..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. @@ -65,11 +65,21 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy Run the narrowest rung that covers what you touched; escalate only when the change surface demands it. 1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck. -2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`). +2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key). 3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit. 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 673aa978da..eb074e5011 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -13,7 +13,7 @@ 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, 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/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/index.ts b/packages/client/runtime/src/client/index.ts index 4c0bf3d01f..830d1b8249 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,30 +1,32 @@ -/** - * Browser runtime services for slots, sessions, and connection-stream - * delivery. The web shell mounts this static client entry through the host - * plugin graph. - */ +/** 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' 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' +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' export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' @@ -57,6 +59,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Props injected into every global slot component. */ interface GlobalStandardProps { useSessions: SnapshotSelectorHook + /** Selector hook over real Workspaces and their independent baseline lifecycle. */ + useWorkspaces: SnapshotSelectorHook } } @@ -72,6 +76,7 @@ declare module 'cordis' { interface Context { slots: import('./slots.ts').SlotsService sessions: import('./sessions/service.ts').SessionsService + workspaces: import('./workspaces/service.ts').WorkspacesService } } @@ -85,10 +90,17 @@ 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/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 af07362f08..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) @@ -300,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) { @@ -315,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 0681e2bb5f..396d0aa798 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -4,14 +4,15 @@ 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,6 +23,12 @@ import { PartialAccumulator } from './partial.ts' /** 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 +} + /** * Owns a session's event window, derived conversation state, and observable * snapshot. React bindings remain outside this data layer. @@ -60,8 +67,18 @@ 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 /** Live events buffered during open/resync and stitched by sequence once history lands. */ private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] @@ -75,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() } @@ -90,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 { @@ -104,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. @@ -271,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 @@ -304,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 { @@ -520,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/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 2b469ca055..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,8 +187,8 @@ describe('cell (render-layer session kit)', () => { const cell = b.svc.cell('s1') expect(cell).toBeDefined() expect(cell?.sessionId).toBe('s1') - // Hook binding happens in React; the cell carries the observable itself. - 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() }) @@ -284,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 536859b59a..e14f110b7c 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -16,7 +16,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { EmptyState } from './skeleton/EmptyState.tsx' /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions'] +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 { @@ -32,6 +32,7 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat */ export function apply(ctx: Context): void { const sessions = ctx.sessions + const workspaces = ctx.workspaces const layout = ctx.layout const slots = ctx.slots @@ -86,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) @@ -103,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 @@ -133,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/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 9745c4518b..095b57a5f8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,6 +1,7 @@ /** 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' @@ -30,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 } } } @@ -94,7 +97,12 @@ export interface ConversationInjected { send(text: string, mode: 'queue' | 'steer'): void /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ stop(): void - 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 } /** @@ -140,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/index.ts b/packages/client/ui-conversation/src/client/index.ts index d55ba1bd1e..a48dfdad34 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -15,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 68fb7c4c31..5ea5ea96ee 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -1,5 +1,5 @@ /** - * Scope-addressed conversation send, cancel, and empty-state session startup. + * 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 @@ -44,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({
-