diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index ffb3afa51a..4e105ef598 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -18,7 +18,7 @@ Persistence is an abstract **capability seam** ([capability seams](2026-06-13-ca Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. +- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) - **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md index 85c62b7e17..1ba3b94e8b 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md @@ -53,9 +53,9 @@ packages/ The package list had been enumerated in five places. The uniform depth-2 layout lets most of them be derived instead: -- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. Root `tsconfig.json` reuses that source map and carries the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.) +- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. The aggregate configs (`tsconfig.host.json`, `tsconfig.client.json`) reuse that source map and carry the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.) - `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages//`), resolving the `TODO(package-inventory)`. -- `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)). +- The aggregates' project `references` stay explicit lists — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)). ### Guardrails added diff --git a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md index ece39654ea..0a6d9b85b1 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -8,13 +8,17 @@ The ACP bridge gives every session its own workspace: `session/new` records the Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the editor project differed from the server launch directory; snapshots hid the bug by making those paths identical. +A valid absolute cwd can itself have two apparent parents: when it contains `symlink/..`, filesystem lookup follows the symlink before applying `..`, while `path.resolve()` erases both components lexically. Resolving sandbox policy lexically while launching bash from the raw cwd granted the unrelated lexical parent, denied writes in the real workspace, and let filesystem tools resolve relative paths into the wrong directory. + +An ordinary symlink cwd exposes the same distinction when the requested relative path contains `..`: a process traverses from the symlink's physical target, while `path.resolve(cwd, path)` traverses from its lexical spelling. Reads would therefore select a different file than bash or a sandboxed mutation for the same model-supplied path. + ## Decision -Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent. +Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. When either the cwd or the requested path contains a parent segment, resolve the cwd to its native filesystem identity before any lexical join; ordinary cwd spellings stay stable for display when no traversal makes their identity observable. Reuse the resolved sandbox-policy root for mutations and sandboxed bash calls so one call has one workspace identity. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent. - `FileSystem.resolve` accepts `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. `opts.signal` cancels resolution when the backend performs I/O. The options object keeps both caller-owned resolution controls together without positional growth. - `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace). -- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. A non-agent / headerless caller yields `undefined`, so the backend applies its default. +- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec, requestedPath)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. The helper uses native realpath semantics when a parent segment in either value could cross a symlink while retaining ordinary spellings otherwise; a sandboxed mutation reuses the complete policy's `workspaceRoot`; a non-agent / headerless caller yields `undefined`, so the backend applies its default. ## Alternatives considered @@ -27,6 +31,7 @@ The default lives in ONE place — the provider's `config.cwd`. `sessionCwd` ret ## Consequences - In the ACP demo the fs tools and bash now agree on each session's workspace; an editor can open any project folder and both tool families act on it. +- A session cwd containing `symlink/..`, or an ordinary symlink cwd paired with a parent-traversing relative path, resolves from the same physical workspace for bash, filesystem tools, and the sandbox grant; the lexical parent receives no grant. - No change to `FsTarget` identity: `targetKey` is still the realpath of the resolved absolute path, so observed-state keying and symlink identity are unaffected — a correct per-session cwd produces the same key bash targets. - Backward compatible: every existing `resolve(path)` call (all in tests) keeps working; the new argument is optional. - The single-session stdio demo is unaffected: it supplies no session cwd (its agent's session has no `cwd`), so resolution falls back to `config.cwd = process.cwd()`, which is the workspace. diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index d3e5608c22..c2f039b148 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-19-gui-layering-and-rpc-protocol.md: ebe21a6060ec69ba9807ab9fbf9906ae24b07823 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 0c256b60ce44a8e16ec6edfba146c776c4ae2129 +2026-07-19-gui-layering-and-rpc-protocol.md: 65fb01f44698c61e6bf6958e332e1854fbb77fa9 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: e8b15789846ea124fb6a90f2afef437184d4348a diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index ebe21a6060..65fb01f446 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -53,7 +53,7 @@ Direction discipline (every rule auditable from package deps): - `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency. - Cross-package client imports use the `/client` subpath for plugin packages (a bare package name would inline a second runtime instance into a browser bundle; the tsdown purity gate rewrites or rejects it). -TypeScript checks in **two aggregate programs** (`tsconfig.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs. +TypeScript checks in **two aggregate programs** referenced by a solution root (`tsconfig.json` = solution; `tsconfig.host.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs ([topology](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md)). On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect). diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 0c256b60ce..e8b1578984 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -51,7 +51,7 @@ harness core packages ──────────────────┘ - `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。 - client 侧跨包 import 插件包一律走 `/client` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundle;tsdown 纯度门禁会改写或拒收)。 -TypeScript 以**两个聚合 program** 检查(`tsconfig.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用。 +TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。 协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 91abd32a3e..1868da83b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-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 -2026-07-19-gui-web-client-architecture.md: 58320570f752d4259004172d3b4527172c2cc646 -2026-07-19-gui-web-client-architecture.zh.md: 744fdaa4b89a01e2710f85b177228713189e3025 +2026-07-19-gui-web-client-architecture.md: f21b840493b83c02d7abc3ba1c1bf90635166ec1 +2026-07-19-gui-web-client-architecture.zh.md: a50dc556cfc96b6d35feea6ef2b1aadae9f31c44 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 58320570f7..f21b840493 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -39,32 +39,17 @@ The loading chain, end to end: **The dual-instance ban**: a module-table package inlined into a plugin bundle would duplicate runtime identity (two React copies, two store registries — the root cause of an actual white-screen P0). The tsdown client preset enforces purity at build time: a bare-name import of a module-table package must resolve external (rewritten to its `/client` form where applicable), and any other workspace leak that is not an inline-safe wire/type layer fails the build (`packages/client/tsdown.client.ts`, pinned by `scripts/client-bundle-purity.spec.ts`). -Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). Type universes stay split at the aggregate level — the root `tsconfig.json` is the host program, `tsconfig.client.json` the client program, because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program. +Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). Type universes stay split at the aggregate level — `tsconfig.host.json` is the host program and `tsconfig.client.json` the client program, both referenced by the solution root `tsconfig.json` — because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program. ## The slot system: how the page composes -A page is a tree of slots; whoever owns a region declares its slots. Contracts live in one place — the `SlotMap` interface in `@deepseek-ai/dsh-client-ui-slots`, extended by declaration merging. An entry declares the slot's axes and the **owner share** only; the registrant's injected props never enter the global table ("whoever injects it, owns its type"): +The slot system has its own RFC — the [slot system standard](2026-07-22-slot-type-chain-implementation.md) — and this document defers to it entirely. The one-paragraph summary for orientation: the shell renders only `'root'`; a plugin composes UI through a single `register` call that occupies a slot, declares+authorizes its child slots (`children` spec object), declares its store, and injects its business face; component props arrive in four auto-derived shares (`PropsRuntime` / `PropsRenderSlots` / `PropsStore` / inject), each from its single source of truth. `SlotMap` declaration merging is the type authority and entries carry only the owner share ("whoever injects it, owns its type"); every rendered entry sits in a per-entry error boundary. -```ts ignore-check -declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { - sidebar: { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps } - conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' } -} } -ctx.slots.define('sidebar', { kind: 'single', scope: 'root' }) // declare=类型,define=落账 -ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) }) -``` - -- Three kinds: `single` (duplicate registration throws), `list` (id/order), `keyed` (runtime dispatch, duplicate key throws). Register before define throws. Two scopes: `root` (no session context) and `session` — the scope decides the injection shape below. -- **Full component props are composed by reference, never re-typed**: a registrant's component declares `OwnerOf & StandardOf & OwnInjected` — the owner share referenced from the slot owner's package, the standard share supplied by the framework (session slots: `useSession`), and the registrant's own injected share declared locally next to the component. `register` enforces the composition at the call site: the component parameter is `SlotComponent>>` (a bare call signature, not `FC` — FC's `propTypes` static position generates contravariance noise against the standard share), and `I` is inferred exclusively from the inject factory's return type (`NoInfer` pins it), so a drifted component or a mismatched factory is a compile error at the registration point. In ui-conversation the injected shares live in `src/client/contract/slots.ts` (`ConversationInjected` and kin) and each skeleton component's props is a one-line reference composition. -- **Delegation is a hand-written whitelist with an optional declared ceiling**: an owner component receives a whitelist-narrowed `slots: ScopedSlots<'a' | 'b'>` through its own props and calls `slots.renderSlot(key, props)`; passing a narrowed subset to a child goes through `narrowSlots` (pure type covariance). Overreach is a compile error, and the runtime whitelist backstops plain-JS callers. An entry may additionally declare `children: ` — register then validates the component's whitelist ⊆ the declared ceiling (opt-in visibility layer, not mandatory). Every rendered entry is wrapped in a per-entry error boundary: a crashing registrant (component or inject factory) blacks out only its own entry, while assembly errors (missing providers) rethrow — a miswired shell fails loud instead of degrading. -- **Props merge from three sources** (the outlet does it; owners write only the first): ① owner-supplied props (identity, display parameters, frozen slices) — typed as the entry's owner share, exact at the renderSlot point; ② scope-standard injection — session slots automatically receive `useSession` bound to the right Session; ③ the registrant's `inject` factory, called once per (entry × session) for session slots and once per entry for root slots, cached in WeakMaps so a session switch-back reuses the cached result. Inject factories receive the assembly handle (`SessionBinding { sessionId, session, ctx }` or `RootBinding { ctx }`) — an apply-world object that never enters React. -- Two supply channels close the loop: `RootBindingProvider` (mounted once by the shell) feeds root-slot inject factories their ctx; `createSessionProvider(deps)` builds the single session provider — dependency-inverted (`useCurrent` / `resolveBinding` / `renderBody`), so web-react never imports the runtime. It subscribes to the current session id, resolves a reference-stable binding, remounts its body under `key={id}`, and delegates body rendering to the assembler's `renderBody` closure (slot ownership stays with layout; the provider knows no slot names). - -Implementation homes: registry core in `packages/client/ui-slots` (zero dependencies), outlet/providers/uSES bridge in `packages/client/web-react`. +Implementation homes: registry core and the props-share types in `packages/client/ui-slots`, outlet/renderer/uSES bridge in `packages/client/web-react`. ## Services and scope addressing -A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`), `ctx.sessions` (list store, scope tree, bindings), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (navigation + panel viewing state), `ctx.conversation` (send/cancel/selection/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters). +A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). Beyond SlotMap, two more typed registration rings follow the same declare-merge idiom: the **view ring** (`ConversationViewMap` — an entry may declare `chromeProps`/`extraProps` extension shapes; `ConvViewPropsOf`/`ChromePropsOf` compose base + extension, so a view with no declaration gets the base for free while ui-trajectory's entries carry real per-view props) and the **tool ring** (tool names stay an open set — no global key table; typing hardens inside the entry: `ToolViewProps.block` is the real `ToolCallBlock` union defined in runtime, and register infers the registrant's injected share like slots do). @@ -101,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── The glue package is the whole ctx↔React boundary; components stay framework-free. -- `createSnapshotStore(init, opts)`: the store engine for plugin-owned data and shell viewing state — zustand vanilla with draft-based updates, `flush: 'sync'` by default (controlled inputs need same-tick echo) with opt-in `'raf'` batching for frame-driven stores, opt-in whole-value localStorage persistence, dev-mode deep freeze. Both a Session object and a snapshot store satisfy the one data contract React consumes: `ObservableSnapshot` (`getSnapshot`/`subscribe`). +- The snapshot store engine **lives in the runtime package** (zustand vanilla with draft-based updates, `flush: 'sync'` by default with opt-in `'raf'` batching, opt-in whole-value localStorage persistence, dev-mode deep freeze — all exported from `runtime`'s `./client` main entry, no subpath): store products are bare observable sources with no hook members. Plugins reach the engine only through `defineStore` declarations per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). web-react composes every hook at the binding site (`bindSnapshotSelector`, per-source cached) from the one data contract React consumes: `ObservableSnapshot` (`getSnapshot`/`subscribe`) — a Session object and a snapshot store both satisfy it. Business plugin packages depend on runtime and ui-slots only; web-react is shell-only glue. - `bindSnapshotSelector(source)`: binds a source into a typed selector hook over uSES-with-selector. The four uSES contract clauses hold by construction: getSnapshot returns the cached reference; subscribe is a bind-time closure (reference-stable forever); pure CSR passes no server snapshot; equality defaults to `Object.is` with `shallowEqual` opt-in per call. - `useInvoke(fn)`: wraps an async action into a stable trigger plus pending flag; pending rides a per-hook external store read through uSES (no setState on the render path), concurrent invocations are counted, and the invoke reference never changes. - Equality protocol, whole chain: producers use structural sharing; consumers short-circuit with `Object.is` or `shallowEqual`; `React.memo` shallow. Deep comparison is banned everywhere. @@ -128,9 +113,9 @@ Domain implementation files never import a sibling domain — shared surfaces ro ## How to develop - **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores, registers slots and toolviews), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically. -- **A new slot**: merge the contract into `SlotMap`, `define` at the owner, render through the owner's own `ScopedSlots` whitelist; registrants `register` with an optional inject factory. Never export components globally. +- **A new slot**: see the [slot system standard RFC](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally. - **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept. -- **Where does this state live**: per-session and must survive switches → the Session object / scope-mounted store; private to one view (selection, scroll) → component state; shell viewing state (navigation, panel widths, preferences) → `ctx.layout`'s stores; business data → always the object layer, never a viewing-state store. +- **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)). - **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 744fdaa4b8..a50dc556cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -39,32 +39,17 @@ Status: implemented **双实例禁令**:模块表包若被内联进插件 bundle,会复制运行时身份(两份 React、两套 store 注册表——一次真实白屏 P0 的根因)。tsdown client 预设在构建期把守纯度:模块表包的裸名 import 必须解析为 external(适用时改写为其 `/client` 形态),其余任何非 inline 安全 wire/类型层的 workspace 泄漏都令构建大声失败(`packages/client/tsdown.client.ts`,由 `scripts/client-bundle-purity.spec.ts` 钉住)。 -dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——根 `tsconfig.json` 是 host program,`tsconfig.client.json` 是 client program,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。 +dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——`tsconfig.host.json` 是 host program、`tsconfig.client.json` 是 client program,二者由 solution 根 `tsconfig.json` 引用,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。 ## slot 体系:页面怎么拼 -页面是一棵坑位树;谁拥有区域谁声明坑位。契约只有一个家——`@deepseek-ai/dsh-client-ui-slots` 的 `SlotMap` 接口,经声明合并扩展。entry 只声明坑的轴与 **owner 份额**;注册方的注入 props 永不进全局表(「谁注入的放谁那里」): +slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-implementation.md)——本文整体移交给它。此处只留一段定位摘要:壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——占坑、声明并授权子坑(`children` spec 对象)、声明 store、注入业务面;组件 props 分四份额自动推导到达(`PropsRuntime` / `PropsRenderSlots` / `PropsStore` / inject),各有唯一真源。`SlotMap` 声明合并仍是类型权威,entry 只携带 owner 份额(「谁注入的,类型归谁」);每个被渲染的注册项都在 per-entry 错误边界之内。 -```ts ignore-check -declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { - sidebar: { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps } - conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' } -} } -ctx.slots.define('sidebar', { kind: 'single', scope: 'root' }) // declare=类型,define=落账 -ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) }) -``` - -- 三型:`single`(重复注册即 throw)、`list`(id/order)、`keyed`(运行时按 key 分发,重 key 即 throw)。define 之前 register 即 throw。两 scope:`root`(无会话语境)与 `session`——scope 决定下述注入形态。 -- **组件全量 props 一律引用组合,不重抄**:注册方组件声明 `OwnerOf & StandardOf & OwnInjected`——owner 份额从坑位 owner 的包引用、标配份额由框架供给(session 坑:`useSession`)、注册方自己的注入份额就地声明在组件旁。`register` 在调用点强制组合:组件形参位是 `SlotComponent>>`(裸调用签名而非 `FC`——FC 的 `propTypes` 静态位对标配份额产生反变噪音),`I` 只从 inject 工厂返回值推断(`NoInfer` 钉死),组件漂移或工厂不匹配都在注册点编译报错。ui-conversation 的注入份额住 `src/client/contract/slots.ts`(`ConversationInjected` 族),各骨架组件的 props 是一行引用组合。 -- **转授=手写白名单+可选声明上限**:owner 组件经自己的 props 拿到白名单收窄的 `slots: ScopedSlots<'a' | 'b'>`,调 `slots.renderSlot(key, props)` 渲染;把收窄子集递给子组件走 `narrowSlots`(纯类型协变)。越权是编译错误,运行时白名单再兜住纯 JS 调用方。entry 可另声明 `children: `——register 校验组件白名单 ⊆ 声明上限(可选可见层,不强制)。每个被渲染的注册项都包在 per-entry 错误边界里:注册方崩溃(组件或 inject 工厂)只黑自己那一格,装配错误(缺 provider)则重抛——接错线的壳大声失败而不是静默降级。 -- **props 三源合并**(出口组件来做;owner 只写第一份):① owner 供参(身份、展示参数、冻结切片)——按 entry 的 owner 份额强类型,renderSlot 点即精确;② scope 标配注入——session 坑自动获得绑定正确 Session 的 `useSession`;③ 注册方的 `inject` 工厂,session 坑 per-(注册项 × 会话) 调一次、root 坑 per-注册项调一次,以 WeakMap 缓存——切回会话时复用缓存结果。inject 工厂收到装配句柄(`SessionBinding { sessionId, session, ctx }` 或 `RootBinding { ctx }`)——apply 世界的对象,永不进入 React。 -- 两条供给通道收拢闭环:`RootBindingProvider`(壳顶部挂一次)为 root 坑 inject 工厂供给 ctx;`createSessionProvider(deps)` 构造唯一的会话 provider——依赖倒置(`useCurrent` / `resolveBinding` / `renderBody`),web-react 永不 import runtime。它订阅当前会话 id、解析引用恒等的 binding、以 `key={id}` 重挂其 body,并把 body 渲染委托给装配方的 `renderBody` 闭包(坑位所有权留在 layout;provider 不认识坑名)。 - -实现的家:注册表纯核在 `packages/client/ui-slots`(零依赖),出口组件/provider/uSES 桥在 `packages/client/web-react`。 +实现的家:注册表核心与 props 份额类型在 `packages/client/ui-slots`,出口组件/渲染器/uSES 桥在 `packages/client/web-react`。 ## 服务与 scope 寻址 -服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`)、`ctx.sessions`(列表 store、scope 树、binding)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(导航 + 面板观看态)、`ctx.conversation`(send/cancel/selection/views/startSession)、`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。 +服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/views/startSession)、`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 SlotMap 之外还有两条同 declare-merge 惯例的类型化注册环:**视图环**(`ConversationViewMap`——entry 可声明 `chromeProps`/`extraProps` 扩展形状;`ConvViewPropsOf`/`ChromePropsOf` 组合基座+扩展,无声明的视图免费得基座,ui-trajectory 的两个 entry 带真 per-view props)与**工具环**(tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` union,register 同 slots 一样推断注册方注入份额)。 @@ -101,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── 胶水包就是整条 ctx↔React 边界;组件保持零框架依赖。 -- `createSnapshotStore(init, opts)`:插件自有数据与壳观看态的 store 引擎——zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`(受控输入要求同 tick 回响),帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结。Session 对象与快照 store 同构满足 React 消费的唯一数据契约:`ObservableSnapshot`(`getSnapshot`/`subscribe`)。 +- 快照 store 引擎**住 runtime 包**(zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`,帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结——全部从 `runtime` 的 `./client` 主出口导出,无子路径):store 产物是裸的可观察源,不带任何 hook 成员。插件只经 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 的 `defineStore` 声明触及引擎。web-react 在绑定处(`bindSnapshotSelector`,按源缓存)从 React 消费的唯一数据契约合成每个 hook:`ObservableSnapshot`(`getSnapshot`/`subscribe`)——Session 对象与快照 store 同构满足它。业务插件包只依赖 runtime 与 ui-slots;web-react 是仅壳可用的胶水。 - `bindSnapshotSelector(source)`:把一个源绑定为经 uSES-with-selector 的带类型 selector hook。uSES 契约四条按构造成立:getSnapshot 恒返缓存引用;subscribe 是绑定期闭包(引用永稳);纯 CSR 不传 server snapshot;相等性缺省 `Object.is`,按调用可选 `shallowEqual`。 - `useInvoke(fn)`:把异步动作包成引用恒定的触发器加 pending 标志;pending 走 per-hook 外部 store 经 uSES 读出(渲染路径零 setState),并发调用计数,invoke 引用永不变。 - 相等性协议,全链一致:生产端结构共享;消费端以 `Object.is` 或 `shallowEqual` 短路;`React.memo` 浅比较。深比较全链禁止。 @@ -128,9 +113,9 @@ src/client/ ## 怎么开发 - **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot 与 toolview),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。 -- **新 slot**:契约合并进 `SlotMap`,owner 处 `define`,经 owner 自己的 `ScopedSlots` 白名单渲染;注册方 `register`,按需带 inject 工厂。永不全局导出组件。 +- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。 - **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。 -- **状态住哪**:per-session 且要跨切换存续 → Session 对象 / scope 挂账 store;单视图私有(选中、滚动)→ 组件状态;壳观看态(导航、面板宽、偏好)→ `ctx.layout` 的 store;业务数据 → 永远对象层,永不进观看态 store。 +- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。 - **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`。 ## Consequences 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 06fed08671..9e671e3672 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: b4ec761b9777f5dfbd59efde8c472f9be4c2e1b6 -2026-07-22-slot-type-chain-implementation.zh.md: 28b6e4a3db0c87322582125825492703e62371b2 +2026-07-22-slot-type-chain-implementation.md: 1e9bd711e8316e2556fe238eb0a20d76e1d0d5b1 +2026-07-22-slot-type-chain-implementation.zh.md: 0eab839d033faac2f2c3356900c7ca1cd69d2dc9 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 b4ec761b97..1e9bd711e8 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 @@ -1,47 +1,109 @@ -# Agent Note: Slot type-chain hardening — the non-obvious implementation rulings +# Agent Note: The slot system standard — single register, four props shares, and the framework store seat Status: implemented English | [中文](2026-07-22-slot-type-chain-implementation.zh.md) -> Scope: why the slot registration/render type chain (`packages/client/ui-slots/src/index.ts`, consumed by `packages/client/web-react/src/scoped-slots.tsx`) is implemented the way it is. The design-level trade-offs (registration-site inference over declaration tables, hand-written whitelists over derived ones) live in the web client architecture RFC; this note pins the five implementation decisions a future editor would otherwise re-litigate or accidentally revert. +> Scope: the definitive slot-system design for the web client — how UI plugins compose the page, where render authority lives, how component props are typed, and where business live-data goes. The [web client architecture RFC](2026-07-19-gui-web-client-architecture.md) owns the surrounding context (loading chain, object layer, services) and defers its slot sections here. ## Problem -The hardened chain types every hop from `SlotMap` declaration to rendered component: owner share + framework-standard share + registrant-injected share compose into the component's props, checked at `register()`. Making that constraint hold without false rejections forced five choices that look arbitrary from the code alone — each one exists because the obvious alternative fails in a specific, reproducible way. +The page is composed at runtime from independently loaded plugins, so the UI needs a composition mechanism that answers four questions with static force. Who may render into a region — and is that authority enforceable, or merely conventional? How does a component receive everything it needs while staying a pure function (no ctx, no framework imports), without every value being hand-threaded through assembly code? Where does business live-data live so that streaming updates re-render precisely the subscribers — without every plugin building its own subscription machinery? And how much of this can the compiler check, so that a drifted component, an over-reaching render call, or a mismatched store schema is a compile error at one visible call site rather than a runtime surprise? ## Decision -### 1. `SlotComponent

` (bare call signature) instead of `FC

` at the registration position +One sentence: **the shell renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.** -`register()` constrains components as `SlotComponent>>` where `SlotComponent

= (props: P) => ReactNode`. React's `FC` carries static fields (`propTypes`, `defaultProps`) whose types reference `P` in covariant positions; assignability between two `FC` instantiations therefore checks those statics too, and the bottom-typed standard share (see ruling 4's `useSession: never`) makes those covariant checks reject components that narrow it — precisely the components the design wants to accept. The bare call signature checks through clean parameter contravariance only. Components stay ordinary functions; nothing observable changes at runtime. +### 'root' is the only a-priori slot -### 2. `NoInfer` pins the registrant share's inference to the inject factory +`SlotsService` (client runtime) declares `'root'` at construction — single/root, `owner: {}` — and its `SlotMap` merge lives in the runtime package. The shell's entire assembly is `ctx.slots.renderSlot('root', {})`: the only ctx-level render entry; any other key, a missing renderer, or an unregistered root fails loud (no fallback). -`I` (the registrant's injected share) must be inferred from the `inject` factory's return type — the single authoritative source. Without `NoInfer`, TS also collects inference candidates from the component parameter position, and a drifted component (consuming a key the factory does not supply) silently WIDENS `I` to make the call check, absorbing the drift instead of reporting it. `NoInfer` at the component position removes that candidate site, so negative sample ⑥ (a hand-drifted copy of the owner share fails at `register`) actually fails — with inference bleed it would pass. If the `NoInfer` ever gets "simplified away", the type-chain spec's expect-error site goes red first. +### register is the single API; children = declaration + authorization + runtime spec -### 3. `ComposedProps` dispatches on the entry's `owner` key for progressive migration +```ts ignore-check +ctx.slots.register({ + name: 'root', + children: { + 'sidebar': { kind: 'single', scope: 'root' }, + 'conversation': { kind: 'single', scope: 'session' }, + }, + store: createLayoutStore, // StoreHandle or factory (below) + inject: injectFrame, // business face (below) +}, AppFrame) +``` -`ComposedProps` composes `owner & standard & I` only when the SlotMap entry declares an `owner` share; entries without one fall back to the legacy full-`props` constraint (`PropsShape`). This conditional is the migration seam: legacy declarations keep compiling unchanged while entries opt into the composed model one at a time, and both forms flow through the same `register()` overload — no parallel API, no flag. Removing the fallback branch is the flip-the-switch moment for the whole repo, not a cleanup. +There is no separate slot-definition API. The `children` object both **declares the child slots into existence** and **authorizes this component to render them** — a slot is a hole in the render tree that exists because someone will render it, so its lifecycle is the declaring entry's lifecycle (entry disposed → slots gone, contributions cleared). The values are the runtime spec (`kind`/`scope` drive outlet iteration and binding selection; `SlotMap` is types-only and erased at runtime, which is why an array of keys could not work), statically checked against the `SlotMap` entry so type and value are declared at one point and cross-validated. -### 4. The standard share is bottom-typed, and bare `register` bivariance is accepted, not fought +Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes. -Session slots' framework-supplied hook is constrained as `{ useSession: never }` (`StandardOf`): `never` in a parameter-ish position means any registrant narrowing (e.g. a runtime-typed conversation hook) is accepted, and the responsibility for what actually arrives lives with the injecting renderer. Known boundary rider: for components typed with METHOD syntax or otherwise bivariant parameter positions, TS can accept a `register` call it strictly shouldn't (parameter bivariance is unsound by design in TS). The accepted stance is documented rather than tested: we do not add negative samples that depend on strictness TS does not guarantee — they would pin compiler-version behavior, not our contract. The samples we do pin (six expect-error sites in `packages/client/ui-slots/tests/type-chain.spec.tsx`) all fail for contract reasons. +`SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type"). -### 5. `ChildrenChecked` is an opt-in validation layer keyed on the entry's `children` declaration +### Component props: four shares, each from its own source of truth -Sub-slot delegation authority stays a hand-written whitelist (`slots: ScopedSlots<'a' | 'b'>` in the component's own props). `ChildrenChecked` adds an optional second check: only when the entry declares `children` does the component's `slots` face get validated against the authorized union (violation collapses `slots` to `never`, surfacing at the register call). Entries without `children` pass through untouched. The hook point is inside `ComposedProps` — i.e. it fires exactly at the registration boundary, not at render — because register is where both halves (entry declaration, component face) are statically visible at once; a render-time check would need runtime plumbing for a purely static guarantee. +| Share | Type | Source of truth | Contents | +|---|---|---|---| +| runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` | +| child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S | +| store | `PropsStore` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) | +| business | `I` | inject return type | plain data + callbacks (hooks banned) | + +`sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API. + +### The store seat: framework engine, registrant schema + +The framework owns exactly one subscription machine: the snapshot store engine (zustand vanilla + immer + optional localStorage persistence) lives in the **runtime package** (`./client` main entry — no subpath), producing bare observable sources; web-react binds them into hooks at the outlet (per-source cached uSES binding). What a store *contains* is the registrant's declaration, written as a factory so no module-level handle exists (a module-scoped handle would be a de-facto singleton surviving plugin reloads): + +```ts ignore-check +export function createChatStore() { + return defineStore({ + init: () => ({ selection: null as SelectionTarget | null, draft: '' }), + persist: 'dsh.conversation.chat', + actions: { + select: (d, t: SelectionTarget) => { d.selection = t }, + clearDraft:(d) => { d.draft = '' }, + }, + }) +} +``` + +One factory, three consumption points: (a) `register` — pass the factory for an exclusive store, or call it once in `apply` and pass the same handle to several registers to share the instance (cross-plugin sharing is constructively impossible: the handle never leaves the package); (b) `PropsStore>` derives the component's store share with zero hand-written members; (c) tests call the factory and `.create()` a real engine instance, feeding `useSelector`/`actions` straight in as props — production outlets run the very same `create` path, so there is no second machinery. + +Store scope is **derived from the mounting entry's scope** (session slot → one instance per session, living and dying with the session; root slot → one per entry). Read = `props.useStore`; write = `props.actions.*` only — the raw instance (with `update`/`set`) never reaches a component, so the declared actions are the complete, auditable mutation surface. Production code never calls the factory or `create` outside `apply`. + +### inject: the registrant's business face, on its own ctx + +An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape. + +### 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. + +### Tree context and the renderer seam + +`SessionProvider` is a framework component **delivered as a standard-kit seat**: an entry whose `children` declare a session-scope slot receives it as a prop (type in ui-slots, value injected by the renderer) — components never value-import it. It is self-wired (it reads the runtime's current-session state internally; the assembler passes nothing), render-prop shaped — `children(sessionId)` with an `empty` branch, remounting under `key={sessionId}`. `BindingContext` is machinery-internal; business components see zero React contexts. Inject factories execute inside the outlet on purpose (per-entry error boundaries catch them; a crashing registrant blacks out only its own entry while assembly errors rethrow); the outlet reads tree context as a machinery-only implicit parameter — the "identity from the register closure, situation from the tree position" split. + +Rendering lives behind an install seam so the runtime stays React-free: `SlotRenderer` (interface in ui-slots, implementation `createSlotRenderer()` in web-react) is installed once at shell boot via `ctx.slots.install(...)`; double install and render-before-install throw. Ownership bookkeeping is a single `Map` in the service — ledger, slots, contributions, render bindings, and store instances all live and die on the one entry axis, which closes the stale-authority window across plugin reloads by construction (a disposed entry's captured `renderSlot` throws a stale-authorization error on entry). + +### Type-chain implementation rulings + +Two hardening decisions in the register signature exist because the obvious alternative fails in a specific, reproducible way; a future editor should not re-litigate them: + +1. **`SlotComponent

` (bare call signature) instead of `FC

` at the registration position.** React's `FC` carries static fields (`propTypes`, `defaultProps`) whose types reference `P` in covariant positions; assignability between two `FC` instantiations checks those statics too and rejects components the design wants to accept. The bare call signature checks through clean parameter contravariance only; components stay ordinary functions. +2. **`NoInfer` pins the business share's inference to the inject factory.** Without it, TS also collects inference candidates from the component parameter position, and a drifted component (consuming a key the factory does not supply) silently widens `I` to make the call check — absorbing exactly the drift the chain exists to catch. The negative-sample spec pins this: if the `NoInfer` is ever "simplified away", the expect-error site goes red first. ## Consequences -The register call site is now the chain's single choke point: share drift, missing inject keys, unauthorized sub-slot faces, and keyed/list option omissions all surface there at compile time, and the six-sample negative spec pins each failure mode. Costs: the conditional types make hover-signatures at register sites noticeably wider; the bottom-typed standard share shifts arrival-type responsibility onto web-react's renderer (documented on `StandardOf`); and the bivariance boundary means one unsound-accept class is knowingly tolerated. +Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls. Every props surface is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning. ## Alternatives considered | Rejected | One-line reason | |---|---| -| Keep `FC` and cast at register sites | The casts hide exactly the drift the chain exists to catch; FC statics' covariant noise is the mechanical cause, so remove the noise, not the check | -| Infer `I` from the component parameter | Inference bleed absorbs props drift silently — negative sample ⑥ becomes unwritable | -| Big-bang migration to composed props | Every SlotMap declarant lands in one PR; the `owner`-keyed conditional lets entries migrate one by one with both forms live | -| Test the bivariant-accept edge as a negative sample | Would pin TS soundness behavior we don't own; compiler upgrades would break the spec without any contract change | -| Derive delegation whitelists from `children` declarations | The hand-written face is the API the component author reads; derivation inverts ownership and was rejected at design level — `ChildrenChecked` validates instead of generating | +| Separate define/register two-step API | The split leaves render authority unenforced and invites ordering bugs; children-in-register settles declaration, authorization, and spec in one visible place | +| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks | +| Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything | +| `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn | +| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine | +| Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation | +| Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact | +| `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) | 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 28b6e4a3db..0eab839d03 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 @@ -1,47 +1,109 @@ -# Agent Note: slot 类型链硬化——五条非显然实现裁定 +# Agent Note: slot 体系标准——单一 register、props 四份额与框架 store 席位 Status: implemented [English](2026-07-22-slot-type-chain-implementation.md) | 中文 -> 范围:slot 注册/渲染类型链(`packages/client/ui-slots/src/index.ts`,消费方 `packages/client/web-react/src/scoped-slots.tsx`)为什么这样实现。设计层取舍(注册点推断优于声明表、手写白名单优于派生)住 Web 客户端架构 RFC;本文钉住五条实现决定——不写下来,将来的编辑者要么重新争论一遍,要么不经意地回退它们。 +> 范围:Web 客户端 slot 体系的终版设计——UI 插件如何拼合页面、渲染权威落在哪里、组件 props 如何定型、业务活数据住在哪里。周边语境(装载链、对象层、服务)归 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md) 所有,其 slot 各节移交本文。 ## Problem -硬化后的类型链给从 `SlotMap` 声明到组件渲染的每一跳定型:owner 份额 + 框架标配份额 + 注册方注入份额组合成组件 props,在 `register()` 处校验。让这条约束既成立又不误伤,逼出了五个单看代码显得任意的选择——每一个的存在都是因为显然的替代方案会以一种具体的、可复现的方式失败。 +页面在运行时由各自独立装载的插件拼合而成,UI 因此需要一套能以静态强制力回答四个问题的组合机制。谁可以渲染进某块区域——这份权威是可强制执行的,还是仅靠约定?组件如何在保持纯函数(零 ctx、零框架 import)的同时拿到它需要的一切,而不必把每个值都经装配代码手工穿线?业务活数据住在哪里,才能让流式更新恰好只重渲染订阅者——而不必每个插件自建一套订阅机械?以及这一切有多少能交给编译器检查,让漂移的组件、越权的渲染调用、错配的 store schema 成为单一可见调用点上的编译错误,而非运行时的意外? ## Decision -### 1. 注册位用 `SlotComponent

`(裸调用签名)而非 `FC

` +一句话:**壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占坑、声明并授权子坑、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。** -`register()` 以 `SlotComponent>>` 约束组件,其中 `SlotComponent

= (props: P) => ReactNode`。React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性因此连这些静态位一起查,而 bottom 型的标配份额(见裁定 4 的 `useSession: never`)使这些协变检查拒绝掉收窄它的组件——恰恰是设计想接受的那批组件。裸调用签名只走干净的参数逆变检查。组件仍是普通函数;运行时零可见差异。 +### 'root' 是唯一的先验坑 -### 2. `NoInfer` 把注册方份额的推断钉在 inject 工厂上 +`SlotsService`(client 运行时)在构造时声明 `'root'`——single/root、`owner: {}`——其 `SlotMap` 合并声明住 runtime 包(package)。壳的全部装配就是 `ctx.slots.renderSlot('root', {})`:唯一的 ctx 级渲染入口;传任何其他键、渲染器未安装、root 无人注册,一律大声失败(无 fallback)。 -`I`(注册方注入份额)必须从 `inject` 工厂的返回类型推断——唯一权威源。没有 `NoInfer` 时,TS 还会从组件参数位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默地把 `I` 加宽到让调用通过,把漂移吸收掉而不是报出来。组件位的 `NoInfer` 移除了那个候选位,负样本⑥(owner 份额的手抄漂移件在 register 处失败)才得以成立——有推断渗漏时它会通过。将来若有人把这个 `NoInfer`「顺手简化」掉,类型链 spec 的 expect-error 位会第一个变红。 +### register 是唯一 API;children = 声明+授权+运行时 spec -### 3. `ComposedProps` 按条目的 `owner` 键分派,支撑渐进迁移 +```ts ignore-check +ctx.slots.register({ + name: 'root', + children: { + 'sidebar': { kind: 'single', scope: 'root' }, + 'conversation': { kind: 'single', scope: 'session' }, + }, + store: createLayoutStore, // StoreHandle or factory (below) + inject: injectFrame, // business face (below) +}, AppFrame) +``` -`ComposedProps` 只在 SlotMap 条目声明了 `owner` 份额时才组合 `owner & standard & I`;未声明的条目回落到 legacy 全量 `props` 约束(`PropsShape`)。这个条件类型就是迁移接缝:legacy 声明原样编译,条目逐个转入组合模型,两种形态走同一个 `register()`——无平行 API、无开关旗。删掉回落分支的那一刻=全仓切换时刻,不是一次清理。 +不存在独立的坑位定义 API。`children` 对象同时做两件事:**把子坑声明出来**,并**授权本组件渲染它们**——坑是渲染树上的一个洞,因为有人要渲染它才存在,所以坑的生命周期就是声明它的 entry 的生命周期(entry 一经 dispose(资源释放),坑随之消亡、坑内既有贡献清空)。children 的值是运行时 spec(`kind`/`scope` 驱动 outlet 的迭代形态与 binding 选择;`SlotMap` 是纯类型、运行时即被擦除,这正是键数组形行不通的原因),并与对应 `SlotMap` entry 静态对齐校验——类型与值在同一点声明、交叉验证。 -### 4. 标配份额 bottom 型化;裸 `register` 的双变接受面认账不硬测 +对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下。 -session 坑的框架供给 hook 约束为 `{ useSession: never }`(`StandardOf`):参数性位置上的 `never` 意味着任何注册方收窄(如 runtime 定型的会话 hook)都被接受,实际到达什么的类型责任归注入侧渲染器。已知边界搭车项:对以方法语法定型或参数位本就双变的组件,TS 可能接受一个严格意义上不该过的 `register` 调用(参数双变是 TS 的有意不健全)。这个立场以文档记账而不加测试:我们不写依赖 TS 并不承诺的严格性的负样本——那钉住的是编译器版本行为,不是我们的契约。真正钉住的六个 expect-error 位(`packages/client/ui-slots/tests/type-chain.spec.tsx`)全部因契约原因失败。 +`SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。 -### 5. `ChildrenChecked` 是按条目 `children` 声明挂载的 opt-in 校验层 +### 组件 props:四份额,各有唯一真源 -子坑转授权威仍是手写白名单(组件自己 props 上的 `slots: ScopedSlots<'a' | 'b'>`)。`ChildrenChecked` 加一层可选的第二道检查:仅当条目声明了 `children`,组件的 `slots` 面才对照授权并集校验(越界时 `slots` 坍缩为 `never`,在 register 调用处暴露)。未声明 `children` 的条目原样通过。挂点选在 `ComposedProps` 内部——即恰好在注册边界而非渲染期起效——因为 register 是条目声明与组件面两个半边同时静态可见的唯一位置;渲染期检查要为一个纯静态保证铺运行时管线。 +| 份额 | 类型 | 真源 | 内容 | +|---|---|---|---| +| 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` | +| 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S | +| store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | +| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) | + +凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。 + +### store 席位:引擎归框架,schema 归注册方 + +框架拥有恰好一台订阅机械:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**(`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例): + +```ts ignore-check +export function createChatStore() { + return defineStore({ + init: () => ({ selection: null as SelectionTarget | null, draft: '' }), + persist: 'dsh.conversation.chat', + actions: { + select: (d, t: SelectionTarget) => { d.selection = t }, + clearDraft:(d) => { d.draft = '' }, + }, + }) +} +``` + +一个工厂,三个消费点:① `register`——独占 store 直接传工厂;要共享实例,则在 `apply` 里调用一次工厂、把同一句柄传给多次 register(跨插件共享构造性不可能:句柄从不出包);② `PropsStore>` 推导出组件的 store 份额,零手写成员;③ 测试自己调用工厂并 `.create()` 出真引擎实例,把 `useSelector`/`actions` 直接当 props 喂进去——生产 outlet 走的正是同一条 `create` 路径,不存在第二套机械。 + +store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会话一个实例,随会话生灭;root 坑→每个 entry 一个)。读 = `props.useStore`;写 = 仅 `props.actions.*`——裸实例(带 `update`/`set`)永远到不了组件,声明的 actions 就是完整且可审计的变更面。生产代码在 `apply` 之外从不调用工厂或 `create`。 + +### inject:注册方的业务面,立足自己的 ctx + +inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。 + +### 数据界线纪律 + +hook 只许框架造:`useSession`、`useSessions`、`useStore`、`renderSlot` 是仅有的四席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 + +### 树上语境与渲染器安装缝 + +`SessionProvider` 是框架组件,**以标配席形式送达**:`children` 里声明了 session scope 坑的 entry 经 prop 收到它(类型住 ui-slots,值由渲染器注入)——组件永不对它做值 import。它框架自接线(内部自读 runtime 的当前会话状态,装配方零传参),render-prop 形——`children(sessionId)` 外加 `empty` 分支,以 `key={sessionId}` 重挂。`BindingContext` 属机械内部;业务组件可见的 React Context 为零。inject 工厂有意在 outlet 内部执行(per-entry 错误边界接得住它们;崩溃的注册方只黑掉自己那一格,装配错误则重抛);outlet 把树上语境当作仅机械可用的暗参读取——即「身份出自 register 闭包、现场出自树位置」的分工。 + +渲染住在一条安装缝之后,runtime 因此保持 React-free:`SlotRenderer`(接口住 ui-slots,实现 `createSlotRenderer()` 住 web-react)在壳 boot 时经 `ctx.slots.install(...)` 安装一次;双重安装与安装前渲染均 throw。归属记账是服务里的单一 `Map`——账本、坑、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。 + +### 类型链实现裁定 + +register 签名里的两条硬化裁定之所以存在,是因为显然的替代方案会以具体、可复现的方式失败;将来的编辑者不应重新争论它们: + +1. **注册位用 `SlotComponent

`(裸调用签名)而非 `FC

`。** React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性检查连这些静态位一起查,会拒绝设计本想接受的组件。裸调用签名只走干净的形参逆变检查;组件仍是普通函数。 +2. **`NoInfer` 把业务份额的推断钉在 inject 工厂上。** 没有它,TS 还会从组件形参位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默把 `I` 加宽到让调用通过——恰好吸收掉类型链本要抓的漂移。负样本 spec 钉住这一点:若这个 `NoInfer` 日后被「顺手简化」掉,expect-error 位会第一个变红。 ## Consequences -register 调用点成为全链唯一收口:份额漂移、inject 键缺失、越权子坑面、keyed/list options 缺省全部在编译期于此暴露,六样本负样本 spec 逐一钉住失败模式。代价:条件类型让 register 位的悬停签名明显变宽;bottom 型标配份额把到达类型的责任转给 web-react 渲染器(记录于 `StandardOf`);双变边界意味着一类不健全接受被知情容忍。 +渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。 ## Alternatives considered | Rejected | One-line reason | |---|---| -| 保留 `FC`、在 register 位 cast | cast 恰好藏起类型链要抓的漂移;FC 静态位的协变噪音是机械成因,该移除噪音而非移除检查 | -| 从组件参数位推断 `I` | 推断渗漏静默吸收 props 漂移——负样本⑥无从写起 | -| 组合 props 一次性全仓迁移 | 所有 SlotMap 声明方挤进一个 PR;`owner` 键分派让条目逐个迁移、两形态共存 | -| 给双变接受边缘加负样本 | 钉住的是我们不拥有的 TS 健全性行为;编译器升级会在契约零变化时打红 spec | -| 从 `children` 声明派生转授白名单 | 手写面才是组件作者读到的 API;派生反转所有权,设计层已否——`ChildrenChecked` 做校验不做生成 | +| 独立的 define/register 两步式 API | 拆分让渲染权威无从强制、招来时序 bug;children 进 register 让声明、授权、spec 在同一个可见位置结清 | +| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 | +| 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 | +| `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 | +| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 | +| 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 | +| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 | +| 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) | diff --git a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml new file mode 100644 index 0000000000..d218c2a0b2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.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-22-tui-interactive-extension-service.md: 82e7c751b6e5b7500f9f7d7004fda8b905dccabb +2026-07-22-tui-interactive-extension-service.zh.md: d7340e3f5dcf45e95b2d6e15ce3fc33726a555ae diff --git a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md new file mode 100644 index 0000000000..82e7c751b6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md @@ -0,0 +1,41 @@ +# Agent Note: Effect-owned TUI interactive extensions + +Status: implemented + +English | [中文](2026-07-22-tui-interactive-extension-service.zh.md) + +## Problem + +Cordis plugins can register human commands through `ctx.commands`, but a command that needs terminal interaction has no supported presentation boundary. It must either remain non-interactive or capture the TUI's private pi-tui tree, focus state, renderer, and shutdown lifecycle. That coupling makes the extension depend on one front door's internals, lets independently developed overlays compete for focus, and leaves plugin unload with no reliable way to remove queued or visible UI. + +## Decision + +A mounted `@deepseek-ai/dsh-tui` provides `ctx.tui` after terminal startup succeeds. The service belongs to that exact terminal and agent, disappears before terminal teardown, and causes plugins that inject it to unload and reload with provider availability. Other front doors do not emulate it. + +`ctx.tui.openOverlay()` is the first and only interactive extension primitive. It accepts a component factory, constrained layout options, and an optional abort signal. The factory receives a frozen host with the current viewport, semantic theme functions, display-text escaping, redraw, close, and a lifetime signal. It does not receive the pi-tui `TUI`, overlay handle, editor, transcript tree, focus controller, or terminal object. + +One private overlay manager serializes built-in and plugin requests in FIFO order. The model selector and `ctx.userInteraction` question panel use the same manager, so all modal interaction has one focus owner. Closing the active overlay restores pi-tui's previous focus before the next request activates. Overlay state is process-local presentation: it is neither appended to the session log nor rebuilt during resume. + +The service method runs through Cordis's traceable service proxy. It installs an effect on the calling plugin fiber before admitting the request; caller disposal therefore removes a queued request or closes an active overlay and awaits the same settled outcome. TUI shutdown first rejects admission, then disposes the service fiber so dependent plugins and their effects quiesce, settles remaining built-in work, and only then drains and stops the terminal. + +Component construction, rendering, input, and invalidation run behind an exception boundary. A failure closes that request with an `error` outcome, reports a visible terminal error, and lets the queue continue. Components are trusted package code: their rendered lines may contain ANSI styling, and they must call `host.display()` before including untrusted text. + +## Verification + +Manager tests pin FIFO admission, cancellation, repeated close, shutdown outcomes, guarded callbacks, host capabilities, and per-file coverage. Cordis lifecycle tests pin caller ownership, provider loss and return, unloading-time rejection, and cleanup quiescence. Fake-terminal integration tests exercise plugin overlays alongside built-in questions, restored editor input, terminal remount, startup rollback, and service disappearance. Existing TUI interaction tests continue to exercise the model selector and question panel through the shared path. + +## Alternatives considered + +**Expose pi-tui objects directly.** This gives plugins maximum freedom but makes private focus, rendering, and teardown state a public compatibility contract. It also cannot arbitrate independently loaded overlays. + +**Put interactive callbacks on command definitions.** Commands are shared by TUI and ACP and remain useful without a terminal. Adding terminal state to `ctx.commands` would couple discovery and dispatch to one presentation implementation. + +**Create a complete TUI slot and action framework at once.** Actions, editor replacement, transcript renderers, status regions, and completion providers have different composition and conflict rules. Shipping them behind one broad API would freeze those rules before a concrete consumer proves them. + +**Persist open overlays in session events.** Modal presentation is not model-visible session state, and arbitrary component state is not replayable. The plugin that owns durable data records that data through its domain service and recreates presentation when appropriate. + +## Consequences + +Interactive plugins gain a small stable front door with deterministic focus and Cordis-owned cleanup, while the TUI keeps authority over terminal lifecycle and pi-tui internals. Built-in dialogs and extensions cannot overlap or strand focus. + +The API deliberately covers modal overlays only. Human command registration remains on `ctx.commands`; actions, slots, editor replacement, event renderers, and completion providers require separate contracts when real consumers establish their ordering and ownership semantics. FIFO serialization also means one stalled overlay blocks later modal work until its owner closes, aborts, or unloads it. diff --git a/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md new file mode 100644 index 0000000000..d7340e3f5d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 由 effect 持有的 TUI 交互扩展 + +Status: implemented + +[English](2026-07-22-tui-interactive-extension-service.md) | 中文 + +## 问题 + +Cordis 插件可以通过 `ctx.commands` 注册用户命令,但需要终端交互的命令没有受支持的呈现边界。它只能保持非交互,或者捕获 TUI 私有的 pi-tui 树、焦点状态、渲染器和关闭生命周期。此类耦合会使扩展依赖某个入口的内部实现,让各自独立开发的浮层争抢焦点,并导致插件卸载时无法可靠移除排队中或已显示的 UI。 + +## 决策 + +挂载的 `@deepseek-ai/dsh-tui` 在终端成功启动后提供 `ctx.tui`。该服务只属于挂载时绑定的终端与 agent(智能体),在终端拆卸前消失,并使注入它的插件随着提供方的可用与否卸载和重新加载。其他入口不会模拟该服务。 + +`ctx.tui.openOverlay()` 是第一个也是唯一一个交互扩展原语。它接受组件工厂、受限的布局选项,以及可选的中止信号。工厂收到一个冻结的 host,其中包含当前视口、语义化主题函数、显示文本转义、重绘、关闭和生命周期信号。它不会收到 pi-tui `TUI`、浮层句柄、编辑器、transcript(文本记录)树、焦点控制器或终端对象。 + +一个私有浮层管理器按 FIFO 顺序串行处理内置请求和插件请求。模型选择器与 `ctx.userInteraction` 问题面板使用同一个管理器,因此所有模态交互只有一个焦点所有者。关闭活动浮层时,系统会先恢复 pi-tui 之前的焦点,再激活下一项请求。浮层状态是进程本地的呈现状态:它既不会追加到会话日志,也不会在恢复期间重建。 + +服务方法通过 Cordis 的可追踪服务代理运行。它在接纳请求前,向调用方插件的 fiber 注册一个 effect;因此,调用方执行 dispose(资源释放)时会移除排队中的请求或关闭活动浮层,并等待同一个结果完成结算。TUI 关闭时会先拒绝新请求,再 dispose 服务 fiber,让依赖插件及其 effect 完全静止,然后结算其余内置工作,最后才排空并停止终端。 + +组件构造、渲染、输入与失效处理均在异常边界内运行。任何失败都会以 `error` 结果关闭对应请求、在终端中报告一条可见错误,并让队列继续处理。组件属于受信任的包代码:其渲染行可以包含 ANSI 样式,但加入不受信任的文本前必须调用 `host.display()`。 + +## 验证 + +管理器测试固定了 FIFO 准入、取消、重复关闭、关闭结果、受保护回调、host 能力和逐文件覆盖率。Cordis 生命周期测试固定了调用方所有权、提供方消失与恢复、卸载期间的拒绝,以及清理达到完全静止。模拟终端集成测试覆盖插件浮层与内置问题的协作、编辑器输入焦点恢复、终端重新挂载、启动回滚和服务消失。既有 TUI 交互测试继续通过共享路径覆盖模型选择器与问题面板。 + +## 考虑过的替代方案 + +**直接暴露 pi-tui 对象。** 这会赋予插件最大的自由度,却会把私有的焦点、渲染与拆卸状态变成公开兼容性契约,也无法在独立加载的浮层之间进行仲裁。 + +**在命令定义中加入交互回调。** 命令由 TUI 与 ACP 共享,即使没有终端也仍然有用。向 `ctx.commands` 添加终端状态,会让发现与分派流程耦合到某一种呈现实现。 + +**一次性建立完整的 TUI slot 与 action 框架。** action、编辑器替换、transcript 渲染器、状态区域和补全提供方具有不同的组合规则与冲突规则。在具体消费方验证这些规则之前就将其纳入一个宽泛 API,会过早固化这些规则。 + +**将打开的浮层持久化为会话事件。** 模态呈现并非模型可见的会话状态,任意组件状态也无法回放。拥有持久数据的插件应通过自身的领域服务记录这些数据,并在适当时重新创建呈现。 + +## 后果 + +交互式插件获得一个小而稳定的入口,具备确定性的焦点管理和由 Cordis 持有的清理机制;TUI 则继续掌控终端生命周期和 pi-tui 内部实现。内置对话框与扩展无法重叠,也不会遗留失去归属的焦点。 + +该 API 有意只覆盖模态浮层。用户命令仍然在 `ctx.commands` 上注册;action、slot、编辑器替换、事件渲染器和补全提供方需要另行设计契约,等待实际消费方确定其顺序与所有权语义。FIFO 串行处理也意味着,一个停滞的浮层会阻塞后续模态工作,直至其所有者关闭、中止或卸载该浮层。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml new file mode 100644 index 0000000000..5556ed5fa1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.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-21-semantic-session-checkpoints.md: 4bca02fe3893ac39621ed79a000ca8f86db4ff67 +2026-07-21-semantic-session-checkpoints.zh.md: 1f187eb6448a3c9ca6784ec2bddd7295be2706d7 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md new file mode 100644 index 0000000000..4bca02fe38 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md @@ -0,0 +1,29 @@ +# Agent Note: Semantic session checkpoints + +Status: implemented + +English | [中文](2026-07-21-semantic-session-checkpoints.zh.md) + +## Problem + +Persistence buffered every synchronous `session/event` until the loop's final turn checkpoint. A turn is the correct conversational transaction, but it is too coarse as the only crash-recovery point: a hard crash during a long model request or tool call could discard the whole in-flight turn, including the request envelope needed to identify what had been attempted. A tool call with no result was also repaired with one undifferentiated interruption error, so the resumed model could not tell whether execution had started and could retry a side effect blindly. + +## Decision + +`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. It flushes at `agent/post-step` after the assistant message and ordered results are recorded. The loop's existing final `turn/end` checkpoint remains the closing boundary. + +Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/post-step` listeners join this checkpoint; the loop-owned assistant message and ordered results always precede the event. + +Checkpoint failure and cancellation are fail-closed at effect boundaries. A rejected request checkpoint prevents adapter dispatch; a rejected tool checkpoint becomes an error result without invoking the tool body. If cancellation lands while the tool checkpoint is pending, the policy rechecks the signal and returns the canonical `ABORTED_BEFORE_DISPATCH` result. A rejected post-step checkpoint stops continuation before another model request. Persistence serialization continues to belong to the coordinator, so concurrent tool checkpoints cannot duplicate event sequences. + +The ACP app owns its bridge, checkpoint policy, and persistence backend in one ordered Cordis effect. Cordis unloads sibling plugin effects concurrently, so independent mounts would let persistence detach while bridge teardown was still closing an interrupted turn. The composite lifecycle unloads the bridge first, waits for its agents to quiesce and flush the real `step/end` and `turn/end`, then removes checkpoint scheduling and persistence. + +Crash repair distinguishes durable evidence. An assistant tool request without a `tool/call` becomes `TOOL_NOT_STARTED` and may be retried if still needed. A durable `tool/call` without a result becomes `TOOL_OUTCOME_UNKNOWN`; its model-visible result permits retry only for read-only or idempotent operations and directs the model to verify external state or ask the user before deciding about side-effecting work. A provider that supports idempotency keys can receive the stable `callId`, but the Harness does not claim generic exactly-once effects. + +## Alternatives considered + +Flushing every event or streaming chunk minimizes loss but turns local append and `fsync` latency into the hot path and destabilizes streaming throughput. Moving the barriers into `agent-loop` prevents omission for that loop but hides checkpoint policy inside the mechanism and removes Cordis-level replacement and ordering. Keeping turn-only flush preserves throughput but loses the request and execution intent needed for safe recovery. Automatically retrying every unmatched call is safe only for a subset of tools and can duplicate irreversible effects. + +## Consequences + +Hard-crash recovery retains the complete model request, durable tool intent, and complete settled step at the nearest semantic boundary while allowing partial streaming chunks since the previous boundary to remain lossy. Default CLI, TUI, ACP, Python SDK runtime, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, cancellation during a checkpoint, fail-closed behavior, nested dispatch, disposal, and Loader shape; a real child process killed with `SIGKILL` proves request and tool-intent recovery through JSONL, and the shared persistence contract proves both recovery classifications across backends. Keyless ACP snapshots prove both that retry-risk guidance reaches resumed history and the next model turn and that graceful cancellation persists the loop's real closing boundaries. diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md new file mode 100644 index 0000000000..1f187eb644 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 语义会话检查点 + +Status: implemented + +[English](2026-07-21-semantic-session-checkpoints.md) | 中文 + +## 问题 + +持久化机制会缓冲所有同步 `session/event`,直到 agent loop(智能体循环)执行最后的轮次检查点才写入。一个轮次是正确的对话事务,但作为唯一的崩溃恢复点过于粗粒度:如果在耗时的模型请求或工具调用期间发生硬崩溃,整个进行中的轮次都可能丢失,其中包括识别已尝试操作所需的请求封套。系统还会使用同一种不作区分的中断错误,修复没有结果的工具调用,因此恢复运行的模型无法判断调用是否已经开始,可能会盲目重试带有副作用的操作。 + +## 决策 + +`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。它还会在 `agent/post-step` 时刷新会话,此时模型消息与按序结果都已记录。现有的最终 `turn/end` 检查点仍是轮次的收尾边界。 + +持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/post-step` 监听器追加的事件是否会纳入本检查点;循环自身记录的助手消息与有序结果始终先于该事件。 + +检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤后检查点被拒绝时,系统会在发起下一个模型请求前停止继续执行。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。 + +ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持有其桥接层、检查点策略与持久化后端。Cordis 会并发卸载同级插件的 effect;如果分别加载,桥接层仍在为被中断的轮次收尾时,持久化后端就可能已经卸载。组合生命周期会先卸载桥接层,等待其各 agent 达到静止,并刷新真实的 `step/end` 与 `turn/end`,再移除检查点调度与持久化。 + +崩溃修复会区分持久化证据。如果模型发出了工具请求,却没有 `tool/call`,系统会将其标记为 `TOOL_NOT_STARTED`;如果仍有需要,可以重试。如果持久化的 `tool/call` 没有结果,系统会将其标记为 `TOOL_OUTCOME_UNKNOWN`;对应的模型可见结果只允许重试只读或幂等操作,并指示模型在决定是否重试有副作用的工作前,先验证外部状态或询问用户。支持幂等键的模型提供方可以获取稳定的 `callId`,但 Harness 不承诺通用的副作用恰好执行一次保证。 + +## 考虑过的替代方案 + +刷新每个事件或流式分片虽能尽可能减少丢失,但会把本地追加与 `fsync` 延迟带入热路径,破坏流式输出的吞吐稳定性。将这些屏障放入 `agent-loop`,虽能防止该循环漏装,却会将检查点策略隐藏在机制中,并失去 Cordis 层的替换与排序能力。仅保留轮次刷新可以维持吞吐量,但会丢失安全恢复所需的请求与执行意图。自动重试所有未匹配调用只对部分工具安全,可能会重复不可逆的副作用。 + +## 后果 + +发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。无密钥 ACP 快照既证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,也证明取消流程正常收尾时,系统会持久化由循环实际生成的闭合边界。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml new file mode 100644 index 0000000000..9d8ccb1790 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.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-22-collapsed-sidebar-control-rail.md: e959eef37a9e9c0fea79b82ff970daddd9257609 +2026-07-22-collapsed-sidebar-control-rail.zh.md: 7f6d6529a8aa4a655a1d3292e7f41bfb822f05a3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md new file mode 100644 index 0000000000..e959eef37a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -0,0 +1,29 @@ +# Agent Note: A collapsed sidebar retains its control rail + +Status: implemented + +English | [中文](2026-07-22-collapsed-sidebar-control-rail.zh.md) + +## Problem + +The sidebar close action persisted a zero width preference, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout. + +## Decision + +The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 56px: a 24px icon column between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. + +`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`. + +`SidebarRoot` reads the owner `collapsed` prop and morphs in place rather than swapping renders: the four control rows persist into the rail — expand toggle, new session, new workspace, search, in the same top-down order as their expanded rows — animating their geometry (heights, paddings, margins, capsule borders) on the same curve, each aligned with its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box). Wide-only content (brand, labels, input, session tree) cross-fades out over 200ms, stays mounted while the collapse animates, and unmounts once the 300ms settle passes — dropping the sessions subscription and leaving the rendered and accessibility trees. The search query lives with the root and survives the round trip. + +## Alternatives considered + +- **Render an expand button over the center column** — rejected because it recovers only the toggle, not the persistent settings area, and splits sidebar chrome across two package owners. +- **Keep a zero-width grid track and let the rail overflow it** — rejected because the rail would overlap the center column and leave hit testing and responsive geometry disconnected from the grid. +- **Keep the complete sidebar tree mounted and hide it with clipping** — rejected because hidden controls remain in the semantic tree and continue subscribing and rendering even though only two controls belong in the collapsed state. + +## Consequences + +- A collapsed sidebar reserves 56px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior. +- The settings entry remains visible but retains its existing placeholder behavior; this change does not introduce an account or settings screen. +- Layout solver tests pin the compact width, sidebar component tests pin the visible controls, and the keyless real-bundle web smoke test pins collapse and recovery through the assembled client. diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md new file mode 100644 index 0000000000..7f6d6529a8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 侧边栏折叠后保留控制栏 + +Status: implemented + +[English](2026-07-22-collapsed-sidebar-control-rail.md) | 中文 + +## 问题 + +侧边栏关闭操作会持久化宽度偏好 `0`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。 + +## 决策 + +布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 + +`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out` 配 `--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。 + +`SidebarRoot` 读取 owner 的 `collapsed` 属性,原地 morph 而非切换渲染:四个控件行持续存在并演变为控制栏——展开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致——几何(行高、内边距、外边距、胶囊边框)走同一条曲线动画,行为与展开态对应控件对齐(搜索图标会展开侧边栏并聚焦搜索框)。宽态专属内容(品牌标识、文字标签、输入框、会话树)以 200ms 交叉淡出,折叠动画期间保持挂载,300ms settle 后卸载——随之退订会话列表并离开渲染树与可访问性树。搜索关键词由根组件持有,折叠往返后保留。 + +## 曾考虑的替代方案 + +- **在中心列上方渲染展开按钮**:不予采纳,因为这只能恢复开关,无法保留常驻设置区域,同时还会让侧边栏 UI 由两个包(package)分别持有。 +- **保留宽度为零的网格轨道,让控制栏溢出显示**:不予采纳,因为控制栏会与中心列重叠,还会使命中测试和响应式几何关系脱离网格布局。 +- **保持完整侧边栏树挂载,并通过裁切将其隐藏**:不予采纳,因为隐藏控件仍留在语义树中,而且会继续订阅和渲染,尽管折叠状态下只需要两个控件。 + +## 后果 + +- 折叠的侧边栏占用 56px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。 +- 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。 +- 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.i18n.yaml new file mode 100644 index 0000000000..a2fcf167a7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.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-23-thinking-row-disclosure-target.md: f698c3cb0b73bf5c65b5d4b5b3f29de3080e0af6 +2026-07-23-thinking-row-disclosure-target.zh.md: 0fba5c1d8f7beec7300dcd51e118a08d57d0e74f diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.md b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.md new file mode 100644 index 0000000000..f698c3cb0b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.md @@ -0,0 +1,29 @@ +# Agent Note: Thinking rows use one disclosure target + +Status: implemented + +English | [中文](2026-07-23-thinking-row-disclosure-target.zh.md) + +## Problem + +A collapsed reasoning entry presents `Think` and its one-line reasoning summary as one visual row, but an icon-only disclosure control leaves both visible labels inert. Applying title expansion to every tool row would instead break the generic tool-row contract, where the row opens details and only the leading control expands arguments. + +## Decision + +`ToolRow` exposes the opt-in `expandOnRowClick` policy. `ThinkRow` enables it so the title and reasoning summary form one accessible disclosure target; pointer clicks, Enter, and Space toggle the same component-local expanded state. Tool rows that do not opt in retain row-to-details selection and leading-control argument expansion. + +## Verification + +The component spec pins both Think click targets and the unchanged generic tool-row handoff. The keyless browser fixture loads the real sidebar and conversation bundles, opens an authored reasoning session, clicks the summary and title, and checks the disclosure state and expanded body. + +## Alternatives considered + +**Expand every tool row from its title.** Generic tool rows use row clicks for details selection, so sharing this behavior would conflate two controls. + +**Keep icon-only disclosure.** The smallest hit target remains disconnected from the labels that describe the hidden content. + +**Render separate title and summary buttons.** Two controls for one expanded state add duplicate focus stops and ambiguous semantics. + +## Consequences + +Thinking rows gain a larger pointer target and keyboard disclosure semantics without changing other tool interactions. The generic row component carries one optional policy because disclosure ownership differs between reasoning and tool calls. diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.zh.md b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.zh.md new file mode 100644 index 0000000000..0fba5c1d8f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-thinking-row-disclosure-target.zh.md @@ -0,0 +1,29 @@ +# Agent Note: thinking 行使用单一展开目标 + +Status: implemented + +[English](2026-07-23-thinking-row-disclosure-target.md) | 中文 + +## 问题 + +折叠的推理(reasoning)条目在同一视觉行中呈现 `Think` 和单行推理摘要,但仅图标可展开会让两个可见标签都无法交互。若让所有工具行均可通过标题展开,又会破坏通用工具行的契约:整行负责打开详情,只有前导控件负责展开参数。 + +## 决策 + +`ToolRow` 提供显式启用的 `expandOnRowClick` 策略。`ThinkRow` 启用该策略,让标题和推理摘要组成单一且无障碍的展开目标;鼠标点击、Enter 和 Space 都切换同一个组件本地展开状态。未启用该策略的工具行仍由整行完成详情选择,由前导控件展开参数。 + +## 验证 + +组件测试固定两个 Think 点击目标以及未改变的通用工具行交接行为。无密钥浏览器 fixture(测试前置数据)加载真实的侧边栏与会话 bundle,打开包含推理内容的既定会话,点击摘要与标题,并检查展开状态和展开后的正文。 + +## 考虑过的替代方案 + +**让每个工具行都可通过标题展开。** 通用工具行将整行点击用于详情选择,共享这一行为会混淆两个控件。 + +**保留仅图标展开。** 最小的点击目标仍与描述隐藏内容的标签脱节。 + +**把标题和摘要分别渲染为按钮。** 两个控件共享一个展开状态,会增加重复的焦点停靠点并产生含糊语义。 + +## 后果 + +thinking 行获得更大的鼠标点击目标和键盘展开语义,同时不改变其他工具交互。通用行组件承担一个可选策略,因为推理与工具调用的展开所有权不同。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md index def0604df9..91d85aeded 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md @@ -18,6 +18,14 @@ Background bash tasks carry an opaque owner token equal to the owning session id Connection teardown clears the live map, settles each pending prompt as cancelled, and disposes all `AgentHandle`s in parallel. Each handle stops and awaits its loop, flushes the session while attached, unregisters the agent, and removes the session. Teardown is memoized and shared by client disconnect and plugin disposal. +## Protocol and workspace scope + +[ACP v1 expressly permits several concurrent sessions on one connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24), and each new session carries its own primary `cwd`. This bridge implements that session-level multiplexing, including different primary workspaces as recorded by the [per-session cwd decision](../architecture/2026-07-02-fs-per-session-cwd.md); it does not create one agent subprocess per session. + +A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). [Zed sends the remaining project work directories only when the agent advertises that capability](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1139-L1145), otherwise it [drops them from the session request](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1454-L1472). The bridge does not advertise this capability and rejects non-empty values, as recorded in its [known limitations](../../../../packages/ui/acp/README.md#known-limitations-and-deferred-work), so a current Zed multi-root project reaches it with only the first work directory. + +[The standard transport is one editor-launched agent subprocess per stdio connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42); multiple editor connections therefore require multiple subprocesses or a custom transport, while this decision guarantees multiple sessions within one connection. Within that connection, `ctx.sandboxPolicy` resolves every session's `cwd` as its own `workspace-write` root, so the shared bash and filesystem services can serve concurrent projects without granting cross-project writes. This does not add ACP `additionalDirectories`; it removes the process-wide root limit from the already-supported one-primary-root-per-session path. + ## Alternatives considered **One live session per connection** — rejected. It adds process overhead and contradicts the target client's multi-session shape without removing multiplexing needs from the editor. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index f609473a9c..f5beab3eb4 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -16,7 +16,7 @@ Two forces shape the design. First, compaction policy and reusable token measure Per the [capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: -1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. +1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, the `compact/*` session events, and the canonical checkpoint message source. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. 2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, post-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. 3. **Model-free companion** — `@deepseek-ai/dsh-compact-tool-result-prune`: a concrete optional service that rewrites oversized current `tool/result` nodes before the backend selects a summary range. It is not a second compaction implementation and does not implement `CompactService`. 4. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. @@ -69,13 +69,14 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary -Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed entries *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended: +Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `source: COMPACT_CHECKPOINT_SOURCE` and `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed entries *and* the bookkeeping events. The interface exports that source and `isCompactCheckpointSource()` so consumers recognize a persisted or cloned checkpoint without depending on backend package identity. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended: ``` compact/start → log-only. Acquires the lock. [summarize older range via the backend] compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count. -user/message → surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary). +user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }. + THE surface mutation (framed summary). deriveMessages() renders it as a user-role message. compact/end → log-only. Releases the lock (carries `error` on a recoverable failure). ``` @@ -84,7 +85,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ### Checkpoint framing + incremental merge (backend-private) -The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises only that one replacement user message carries the possibly framed summary. +The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises that one replacement user message carries the possibly framed summary and uses the canonical checkpoint source. ### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy @@ -117,7 +118,7 @@ Two failure paths, both documented: - **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `compact-tool-result-prune` supplies optional deterministic rewriting. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. - **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. -- **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. +- **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results. - **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites. - **Wiring**: `examples/tui-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 3bcb0c2bea..394c0de708 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -12,7 +12,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. `@deepseek-ai/dsh-agent-core` mounts it for both product front doors and forwards its config. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability. The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. @@ -76,7 +76,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. +Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 2cc6516523..507142fac9 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -12,7 +12,7 @@ Confinement alone leaves two gaps. A denial with no escalation path is terminal ## Decision -One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. The scope is deliberately bounded: the phases this Agent Note names but does not design — per-session workspace root, cross-family fs enforcement, the `subagent-acp` consumer, more environments, a Windows chain — are listed under § Deferred phases, each a follow-up design, not a config knob. +One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. Cross-family fs enforcement and per-session workspace roots landed as follow-ups on the same policy carrier; the remaining phases — the `subagent-acp` consumer, more environments, and a Windows chain — stay under § Deferred phases. ### How a deployment uses it @@ -48,7 +48,7 @@ OS subprocess confinement applies to the bash executor, including hook commands, #### The seam: `ctx.sandbox` -`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxPolicy` (mode + workspace root). +`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset). Policy rides each CALL, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is a new call with a wider policy — inexpressible under a config-fixed provider mode. @@ -74,9 +74,9 @@ The model's view is result facts only: the static tool description explains the #### Escalation: one approved wider retry after a denial -`BashExecRequest.sandboxMode` is an optional per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` advertises whether the mounted executor can honor it, so only a confining composition exposes escalation. The seam accepts any explicit mode; the tool owns the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined. +`BashExecRequest.sandboxPolicy` is an optional complete per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` remains the capability fact advertising whether the mounted executor can honor that policy, so only a confining composition exposes escalation. The seam accepts any explicit policy; the tool owns session resolution and the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined. -`SandboxBashExecutor.resolve()` stamps the effective mode — escalation grant > session override > configured default — so `run()`/`start()` read the spec, never the config. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. +`ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted. @@ -115,16 +115,15 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing -- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes. -- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip. -- **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip. -- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. Snapshot mode starts unconfined so unrelated fixtures remain platform-independent; policy scenarios switch explicitly. Real denial stderr stays on platform tests because its dialect is runner-specific. +- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes. +- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip. +- **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. +- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent, and policy scenarios switch explicitly. ## Deferred phases Each phase gets its full design when picked up, validated against the code at that time, and lands with unit, real-API e2e, and snapshot coverage at the tiers it touches. -- **Per-session workspace root** — the executor's write boundary stays config-fixed for its lifetime while each ACP session has its own cwd; a per-session root rides the same per-call policy carrier once designed. Centralizing the root on `ctx.sandboxPolicy` (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)) is the groundwork. - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). - **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. @@ -163,6 +162,7 @@ What shipped pins — the tiers in Testing hold each: - N idle-time flips produce at most one anchored event per knob (a net-zero sequence anchors none — a no-op push from a client echoing current selections records nothing); an approval-policy switch is narrated in at most one coalesced notice; a mid-turn sandbox switch is honored by the next call's stamp. - A resumed session's overrides apply and are reported to the editor with no special-casing; a default changed while the process was down is narrated before the session's first new request, attributed to the operator. - Two concurrent sessions never see each other's state, notices, or config options. +- Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd. - `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/pre-step`, `agent/prompt-submit`, and the ACP handler surface. Costs and accepted limits: @@ -199,7 +199,7 @@ Costs and accepted limits: In-repo precedents this design copies or contrasts with: - [The capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md) — the interface/implementation/consumer split and the "don't split preemptively" timing rule the second consumer satisfied. -- The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the per-call carrier template `sandboxMode` rides, and the explicit-`resolve()` defaulting convention. +- The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the complete `sandboxPolicy` rides its per-call carrier, and the explicit-`resolve()` defaulting convention. - [The approval seam Agent Note](2026-07-06-approval-seam.md) — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there. - [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [the turn-enclosure invariant](../architecture/2026-06-15-turn-enclosure-invariant.md) — the log-as-store foundation the per-session modes fold over, and the commit boundary the anchoring design obeys. - [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` vocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own). diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml index 41246ca3b3..74ad64601b 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.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-14-cross-family-fs-sandbox.md: 0897695cc14b7573ebb53f3ffa6a460652882b37 -2026-07-14-cross-family-fs-sandbox.zh.md: 15de061a0d2b18392f839c927e9b0f5d0cacf28b +2026-07-14-cross-family-fs-sandbox.md: e8a59be345b52f7684c574134b37f48bc49843fc +2026-07-14-cross-family-fs-sandbox.zh.md: 92bc5a495a7c20a08bc85ef9dbf1a1beffe6264f diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md index 0897695cc1..e8a59be345 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md @@ -22,9 +22,10 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching - `Config`: `mode` (the closed `SandboxMode` union, default `read-only`) and `workspaceRoot` (default the process cwd, resolved absolute). Misconfiguration fails loud at load. - The per-session override event `sandbox/mode`, with its pure fold (`effectiveSandboxMode(events)`), its write path (`setSandboxMode(session, mode)`), and `SANDBOX_MODES`. The event is policy state — consumed by two families — so it lives here, not in either capability's seam. Its shape and log-only semantics match the `approval/*` precedent. -- `defaultMode` / `workspaceRoot` accessors the enforcing implementations read for their resolve fallback and boundary. +- `resolve({ session?, mode? })`, which returns a complete per-call `SandboxExecutionPolicy`: explicit approved mode > the session fold > `defaultMode`, and the session's immutable cwd > configured `workspaceRoot` fallback. +- `defaultMode` / `workspaceRoot` accessors retained as deployment fallbacks and the capability-advertisement fact. -`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and reads the default from it; its `resolve()` precedence is unchanged (escalation grant > per-call stamp > default). `dsh-tool-bash` and `dsh-tool-fs` fold the session's `sandbox/mode` with `effectiveSandboxMode` to stamp each call; `dsh-permission` presets and the ACP bridge write through the relocated setter. The seam that owns bash execution no longer depends on `dsh-session` at all — the session dependency moved to the policy package with the fold. +`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and uses its deployment fallback only for direct calls. `dsh-tool-bash` and `dsh-tool-fs` pass the active session to `ctx.sandboxPolicy.resolve()`, so both receive the same effective mode and cwd root on every call; `dsh-permission` presets and the ACP bridge write through the relocated setter. The seams that own bash and fs execution remain session-free — the session dependency lives in the policy package and tool consumers. ### `dsh-fs-sandbox` — enforcement inside the provider @@ -34,13 +35,13 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching - `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Canonical spellings take a lexical containment fast path; when Windows exposes one directory through different casing or long-name/8.3 spellings, an ancestor walk compares filesystem identity rather than weakening the boundary to textual prefix guesses. The target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. - `danger-full-access` delegates unfenced. -A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `sandboxMode` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxMode`); the seam stays session-free (the caller stamps, exactly as `resolve` takes a cwd), and the bare local backend carries-and-ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth. +A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `SandboxExecutionPolicy` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxPolicy`); the seam stays session-free, and the bare local backend ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth. The threat model is stated in the package README: a policy fence in trusted code over model-controlled paths, not a kernel boundary — the operations are the seam's own, only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface (the `code-runtime` "containment, not a security boundary" precedent). Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job. The residual resolve-to-syscall race is narrowed by the in-place re-canonicalization and eliminated only by platform primitives (`openat2` `RESOLVE_BENEATH`) not worth their portability cost here. ### Tool parity — one denial marker, one escalation flow -`dsh-tool-fs` stamps the effective mode onto each mutation and maps `FS_SANDBOX_DENIED` to the marker the model already knows from bash: `[sandbox: file access denied under mode]`. When `ctx.fs.sandboxMode` reports a confining mode at registration, `write` and `edit` advertise the same `sandbox_permissions` + `justification` fields, teach the same same-turn retry, and resolve the same `ctx.approval` request before executing — the four outcomes and their verbatim fail-closed texts carried over from [the sandbox Agent Note](2026-07-06-sandbox.md) § Escalation (strict widening checked at execution against the call's effective mode; a grant consumed by the one call that asked; no new session events). +`dsh-tool-fs` resolves the active session's complete policy onto each mutation and maps `FS_SANDBOX_DENIED` to the marker the model already knows from bash: `[sandbox: file access denied under mode]`. When `ctx.fs.sandboxMode` reports a confining mode at registration, `write` and `edit` advertise the same `sandbox_permissions` + `justification` fields, teach the same same-turn retry, and resolve the same `ctx.approval` request before executing — the four outcomes and their verbatim fail-closed texts carried over from [the sandbox Agent Note](2026-07-06-sandbox.md) § Escalation (strict widening checked at execution against the call's effective mode; a grant changes only that call's mode and retains its session root; no new session events). The shared pieces live in `dsh-sandbox`, which owns the mode types: `WIDER_MODES`, the escalation-target enum, the argument-pairing validation, the denial/hint marker builders, and `approveEscalation` — the ordered fail-closed choreography. `approveEscalation` takes a minimal STRUCTURAL approver (`EscalationApprover`, generic over the agent and call-id types), not the approval service type, so `dsh-sandbox` gains no dependency on the approval or agent packages: each tool passes its own `ctx.approval`, agent, call id, and tool name as ingredients. `dsh-tool-bash` and `dsh-tool-fs` both use these; the cross-file duplication gate holds the single-sourcing honest. @@ -53,7 +54,8 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the ### Out of scope - **Network policy for `ctx.web`** — `SandboxMode` claims file effects only; a web-only network knob while bash `curl` runs free would be a false boundary. Revisit when a bash backend enforces network (bwrap `--unshare-net`, Landlock ABI v4+). -- **The `subagent-acp` consumer** and **per-session workspace root** — unchanged deferred phases of the sandbox RFC; centralizing the root in `ctx.sandboxPolicy` is groundwork for the latter, not its design. +- **The `subagent-acp` consumer** — unchanged deferred phase of the sandbox RFC. +- **Additional writable roots inside one session** — the resolved policy carries one primary `SessionHeader.cwd`; ACP `additionalDirectories` remains a separate bridge and policy design. - **A uniform per-tool sandbox runtime** — remains rejected for the reasons in the sandbox RFC. ## Alternatives considered @@ -66,7 +68,7 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the - **Per-family policy config with a load-time consistency check** — rejected: two homes for one fact, patched by a check that must enumerate every future enforcing family; the policy service makes drift inexpressible instead of detected. - **Keep the override event in `dsh-bash` as `bash/sandbox-mode`** — rejected: the event is policy state consumed by two families; leaving it bash-named forces `dsh-fs-sandbox` to depend on bash vocabulary. Pre-release, the rename is a same-change move with snapshot re-records, no shims. - **Escalation choreography imported from the approval/agent packages into `dsh-sandbox`** — rejected: it would invert the layering (a base vocabulary package depending on UI/agent packages). The structural approver keeps the logic single-sourced in `dsh-sandbox` while the dependencies stay in the tool layer that already holds them. -- **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it churns every `writeText`/`editText` caller and splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `sandboxMode` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam. +- **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `SandboxExecutionPolicy` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam. - **Extra writable-root grants on `SandboxPolicy` now** — deferred unchanged: `writableRoots()` derives from the mode meaning today; ad-hoc grants are an escalation-scope question the sandbox RFC left open. ## Consequences @@ -77,6 +79,7 @@ What shipped — the tiers in § Testing hold each: - Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, a new file created under such a symlink, and alias-equivalent root spellings — denies every escape while admitting the same directory identity on real disks. - A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing. - One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold. +- Concurrent sessions with different cwd roots carry different policies through the same service instances; neither family caches one session's root for the next call. - A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default. - The escalation fields on `write`/`edit` exist exactly when the mounted `ctx.fs` confines, absent under `dsh-fs-local`. - `agent-loop` is untouched — everything rides `ctx.sandboxPolicy`, the `ctx.fs` seam, `SessionEventMap` merging, and the tool-execution pipeline. @@ -90,5 +93,6 @@ Costs and accepted limits: ## Testing -- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, and alias-equivalent spelling) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit. +- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins deployment fallback, session mode/root resolution, explicit-mode precedence, the fold/setter, load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-policy fence and containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, root-ending-in-separator, and alias-equivalent spelling) on a real filesystem, plus per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, complete policy resolution, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` consume the same policy kit. +- Keyless e2e: one real Cordis context creates two agents with different session cwd roots, runs the shipped bash and fs tools concurrently, and world-verifies that own-project writes land while both cross-project writes are denied. - Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once. diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md index 15de061a0d..92bc5a495a 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md @@ -22,9 +22,10 @@ Status: implemented - `Config`:`mode`(封闭的 `SandboxMode` 联合,默认 `read-only`)与 `workspaceRoot`(默认进程 cwd,解析为绝对路径)。配置错误在加载时高声失败。 - per-session 覆盖事件 `sandbox/mode`,连同它的纯折叠(`effectiveSandboxMode(events)`)、写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它住在这里,而不在任一能力的 seam 里。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例。 -- `defaultMode` / `workspaceRoot` 访问器,供执行实现读取其 resolve 回退值与边界。 +- `resolve({ session?, mode? })` 返回完整的单次调用 `SandboxExecutionPolicy`:显式批准的模式 > 会话折叠结果 > `defaultMode`,而会话中不可变的 cwd > 配置的 `workspaceRoot` 回退值。 +- 保留 `defaultMode` / `workspaceRoot` 访问器,作为部署回退值与能力宣告依据。 -`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy` 并从中读取默认值;其 `resolve()` 优先级不变(升级授权 > per-call 盖章 > 默认)。`dsh-tool-bash` 与 `dsh-tool-fs` 用 `effectiveSandboxMode` 折叠会话的 `sandbox/mode` 以对每次调用盖章;`dsh-permission` 预设与 ACP bridge 经由迁移后的 setter 写入。拥有 bash 执行的那个 seam 不再依赖 `dsh-session`——会话依赖随折叠一起迁到了策略包。 +`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy`,仅在直接调用时使用其中的部署回退值。`dsh-tool-bash` 与 `dsh-tool-fs` 把当前会话传给 `ctx.sandboxPolicy.resolve()`,因此两者每次调用都会取得相同的生效模式与 cwd 根目录;`dsh-permission` 预设与 ACP bridge 经由迁移后的 setter 写入。拥有 bash 与 fs 执行的 seam 仍不依赖会话——会话依赖归策略包与工具消费方所有。 ### `dsh-fs-sandbox`——在提供方内部执行 @@ -34,13 +35,13 @@ Status: implemented - `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。规范化路径写法采用词法包含的快速路径;当 Windows 以大小写不同的路径、长文件名或 8.3 短文件名表示同一目录时,系统会逐级遍历祖先目录并比较文件系统身份,而不会把边界弱化为依据文本前缀猜测包含关系。目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。 - `danger-full-access` 不加围栏地委托。 -拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `sandboxMode`(文件系统侧对应 `BashExecRequest.sandboxMode`);该 seam 保持无会话依赖(由调用方盖章,正如 `resolve` 接收一个 cwd),而裸的本地后端携带并忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。 +拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `SandboxExecutionPolicy`(文件系统侧对应 `BashExecRequest.sandboxPolicy`);该 seam 保持无会话依赖,而裸的本地后端会忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。 威胁模型写在包 README 里:一道位于可信代码中、针对模型可控路径的策略围栏,而非内核边界——操作是 seam 自身的,只有目标路径不可信,所以「先规范化再判包含」是对这个面的完整答案(`code-runtime` 的「containment, not a security boundary」先例)。对不可信代码的内核级隔离仍是 `ctx.bash` 的职责。resolve 到系统调用之间残留的竞态被就地重新规范化收窄,只有平台原语(`openat2` `RESOLVE_BENEATH`)能彻底消除它,而那在此不值其可移植性代价。 ### 工具对等——一个拒绝标记、一条升级流程 -`dsh-tool-fs` 把生效模式盖章到每次变更上,并将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,教授相同的同回合重试,并在执行前解析相同的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 RFC](2026-07-06-sandbox.md) § Escalation(严格加宽在执行时针对调用的生效模式检查;授权由发起它的那一次调用消费;无任何新会话事件)。 +`dsh-tool-fs` 把当前会话解析成完整策略,并传给每次变更,同时将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,教授相同的同回合重试,并在执行前解析相同的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 RFC](2026-07-06-sandbox.md) § Escalation(执行时根据调用的生效模式检查是否严格加宽;授权只改变当前调用的模式,并保留其会话根目录;不产生任何新会话事件)。 共享部分住在 `dsh-sandbox`,它拥有模式类型:`WIDER_MODES`、升级目标枚举、参数配对校验、拒绝/提示标记构造器,以及 `approveEscalation`——有序的 fail-closed 编排。`approveEscalation` 接收一个最小的结构化 approver(`EscalationApprover`,对 agent 与 call-id 类型泛型化),而非审批服务类型,所以 `dsh-sandbox` 不获得对 approval 或 agent 包的依赖:每个工具把自己的 `ctx.approval`、agent、call id 与工具名作为原料传入。`dsh-tool-bash` 与 `dsh-tool-fs` 都使用它们;跨文件重复检测门禁确保单一来源不走样。 @@ -53,7 +54,8 @@ Status: implemented ### 范围之外 - **`ctx.web` 的网络策略**——`SandboxMode` 只声明文件效果;在 bash `curl` 畅通时给一个仅限 web 的网络旋钮会是一道假边界。待某个 bash 后端能执行网络(bwrap `--unshare-net`、Landlock ABI v4+)时再议。 -- **`subagent-acp` 消费者** 与 **per-session 工作区根**——沙箱 RFC 未变的延后阶段;把根集中到 `ctx.sandboxPolicy` 是后者的铺垫,而非其设计。 +- **`subagent-acp` 消费者**——沙箱 RFC 中未变的延后阶段。 +- **单个会话中的额外可写根目录**——解析后的策略携带一个主要 `SessionHeader.cwd`;ACP `additionalDirectories` 仍是独立的 bridge 与策略设计问题。 - **统一的 per-tool 沙箱运行时**——因沙箱 RFC 中的理由继续否决。 ## Alternatives considered @@ -66,7 +68,7 @@ Status: implemented - **带加载期一致性校验的 per-family 策略配置**——否决:一个事实两个归属,靠一个必须枚举每个未来执行家族的校验来打补丁;策略服务让漂移不可表达,而非被检测到。 - **把覆盖事件留在 `dsh-bash` 里作 `bash/sandbox-mode`**——否决:该事件是被两个家族消费的策略状态;保留 bash 命名会迫使 `dsh-fs-sandbox` 依赖 bash 词汇。预发布阶段,该改名是同一变更内的迁移,附带快照重录,无任何 shim。 - **把升级编排从 approval/agent 包导入 `dsh-sandbox`**——否决:那会倒置分层(一个基础词汇包依赖 UI/agent 包)。结构化 approver 让逻辑单一来源于 `dsh-sandbox`,而依赖留在本就持有它们的工具层。 -- **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会搅动每一个 `writeText`/`editText` 调用方,并把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `sandboxMode` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。 +- **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `SandboxExecutionPolicy` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。 - **现在就在 `SandboxPolicy` 上加额外的可写根授权**——照旧延后:`writableRoots()` 如今由模式含义推导;临时授权是沙箱 RFC 留下的升级作用域问题。 ## Consequences @@ -77,6 +79,7 @@ Status: implemented - 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录、在这样一个符号链接下新建的文件,以及根路径的等价别名形式——在真实磁盘上拒绝每一种逃逸,同时允许文件系统认定为同一目录的路径。 - 一个被拒的 fs 变更,携带 `sandbox_permissions` + `justification` 重试一次,会经组合的审批链提示;一次授权让恰好那一次调用在更宽的模式下运行且写入落盘;rejected/cancelled/unavailable 各自产生其逐字的 fail-closed 文案且不做任何变更。 - 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandbox/mode` 折叠遵循新模式。 +- cwd 根目录不同的并发会话通过同一组服务实例携带不同策略;两个家族都不会缓存某个会话的根目录供下一次调用使用。 - 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。 - `write`/`edit` 上的升级字段恰好在被挂载的 `ctx.fs` 受限时存在,在 `dsh-fs-local` 下不存在。 - `agent-loop` 未被触动——一切都骑在 `ctx.sandboxPolicy`、`ctx.fs` seam、`SessionEventMap` 合并,以及工具执行管线之上。 @@ -90,5 +93,6 @@ Status: implemented ## Testing -- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。 +- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、以分隔符结尾的根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 使用同一套策略工具集。 +- 无密钥 e2e:一个真实 Cordis 上下文创建两个 agent,其会话的 cwd 根目录各不相同;系统并发运行正式发布的 bash 与 fs 工具,再通过外部可观察结果验证各自在所属项目中的写入成功,而两次跨项目写入都被拒绝。 - 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml new file mode 100644 index 0000000000..f58ed600f0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md: 76354891f557974b953a1b0b805d94330c9f6e69 +2026-07-16-persistent-pty-sessions.zh.md: 86200d70c6eda815a440c44e8b55457b0424edb6 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md new file mode 100644 index 0000000000..76354891f5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -0,0 +1,171 @@ +# Agent Note: persistent PTY sessions + +Status: implemented + +English | [中文](2026-07-16-persistent-pty-sessions.zh.md) + +## Problem + +The harness can run foreground and background commands, edit files, and delegate work, but it cannot continue an interactive terminal conversation across tool calls. Each `bash` foreground run starts a fresh shell, so shell-local cwd, exported variables, virtual-environment activation, functions, job-control state, and interactive child processes end with that call. + +That gap excludes workflows whose state lives in a terminal rather than a file: stepping through `gdb`, exploring in a Python or Node REPL, driving a line-oriented editor such as `ed`, or returning to a shell after interrupting its foreground command. The generic [`ctx.tasks`](../../../../packages/tasks/README.md) runtime retains background-operation handles and output, but it does not provide interactive stdin or terminal semantics. + +The existing `bash`, `read`, `write`, and `edit` tools remain the reliable default for bounded, auditable operations. A PTY is an additional capability for work that genuinely requires terminal state, not evidence that those tools are defective or candidates for removal. + +## Decision + +The optional `packages/pty/` capability family exposes agent-owned, persistent, line-oriented PTY sessions. It follows the repository's [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md), coexists with the existing command and filesystem tools, and does not change `agent-loop`. + +The implementation supports interactive shells and line-oriented REPLs on Linux and macOS. Full-screen terminal applications, keystroke sequences, BEL-triggered control flow, session restoration after process loss, and cross-agent session sharing are explicitly deferred. + +### Package topology + +| Package | Role | ctx key | +|---|---|---| +| `dsh-pty` | `PtyService`, branded `PtySessionId`, backend registry, owner-scoped session contract, and result types | `ctx.pty` | +| `dsh-pty-local` | [`node-pty`](https://github.com/microsoft/node-pty)-based local backend, platform process inspection, bounded terminal buffer, sandbox resolution, and process-tree supervision | registers a backend on `ctx.pty` | +| `dsh-tool-pty` | Six model-facing tools, task-runtime integration for background sends, guidance, and ACP render intents | registers on `ctx.tools` | + +Idle detection is backend behavior, not a second public seam. A remote or container backend may have authoritative readiness signals that do not resemble local `/proc` inspection; every `PtyBackend` therefore returns the common send result while owning its detection mechanism internally. + +### Agent ownership and identity + +`PtyService` stores live sessions process-locally, but every session is owned by the exact `Agent` passed through the tool execution context. The service mints an opaque `PtySessionId`; an optional model-chosen `name` is display metadata and is unique only within that owner. Every operation targets `sessionId`, and `list`/`read`/`signal`/`kill` reject callers other than the owner. + +There are no plugin-load auto-start sessions. `terminal_open` creates a session only during an agent tool call, when ownership and the owning event-sourced session are known. A future declarative startup feature must compose through unpublished agent setup rather than create shared global terminals. + +Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md). + +### Security and process boundary + +A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning: + +- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them. +- It requires `ctx.sandbox` and the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default and wraps the shell argv once; that mode and workspace root remain the process boundary for the PTY lifetime. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass. + +Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary. + +The implementation uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, `resize`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. Platform process inspectors derive foreground process groups and parent/child identity from `/proc` on Linux and `ps` on macOS. + +### Six model-facing tools + +| Tool | Purpose | Result | +|---|---|---| +| `terminal_open` | Create an owner-scoped session from a registered backend type | `{ sessionId, name, type, motd }` | +| `terminal_send` | Send text, optionally submit Enter, and wait for readiness or register a background task | bounded viewport plus wait and session status; background also returns `taskId` | +| `terminal_read` | Read a bounded page from retained scrollback | `{ text, totalLines, lineBegin, lineEnd, truncated }` | +| `terminal_signal` | Send one allowed signal to the current foreground process group | `{ delivered, targetPgid }` | +| `terminal_close` | Close one session and await process-tree quiescence | `{ killed }` | +| `terminal_list` | List the caller's live sessions | owner-scoped session summaries | + +`terminal_send({ sessionId, text, submit?, run_in_background? })` treats `text` as UTF-8 bytes and resolves `submit` to `true` in the tool implementation. When `submit` is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics. + +Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit. + +With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on `ctx.tasks` and returns immediately with `taskId`. `task_output(wait: true)` waits, reads incremental output, and records the final result; `task_kill` forwards cancellation as `SIGINT` and escalates only through the PTY backend's owned teardown path. If the task surface is absent, background mode fails before writing input. No PTY-specific `sleep` tool or general wake-up seam is added. + +`terminal_read` pages backward from the newest retained line. The backend enforces both line and UTF-8 byte caps on retained scrollback and the complete returned value, so one oversized line cannot bypass the bound. `truncated` distinguishes retention loss from an ordinary viewport delta. + +`terminal_signal` accepts the closed set `SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP`. The backend resolves the terminal foreground process group at execution time. `SIGKILL` is rejected when that group is the top-level shell, directing the caller to `terminal_close`; a failed group lookup fails the operation instead of signaling a guessed PID. + +### Local readiness detection + +The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then runs three bounded fallback tiers. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, and `timeoutMs`. + +On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. + +On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path. + +Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session. + +`node-pty` data notifications feed one streaming decoder and terminal parser. Parser carry state handles UTF-8 and terminal query sequences split across chunks. The implementation normalizes line-oriented output and detects alternate-screen entry, but it does not promise correct interaction with a full-screen application. + +### Model-visible output and durability + +The existing durable `tool/call` and `tool/result` events are the source of truth for text sent by the model and rendered output returned to it. `terminal_open` returns its MOTD through the logged tool result; foreground `send`/`read`/`list`/`signal`/`close` results are logged the same way. The PTY packages do not duplicate raw byte streams into custom session events. + +Background sends use the existing task completion notice and `task_output` result path, so any output that reaches a later model request is likewise durable. Raw terminal bytes remain bounded process-local state and are neither persisted nor restorable. A future opt-in transcript sink would need its own retention, credential, and privacy contract. + +### Process-tree teardown + +The top-level `node-pty` child is the ownership anchor. On close, the backend stops callbacks, snapshots that PID and its transitive descendants by parent PID in children-first order, sends `SIGTERM`, closes the PTY, waits for quiescence, then sends `SIGKILL` to verified survivors after configurable `disposeGraceMs` and waits for them to leave the process table. Every captured PID includes process-start identity so reuse cannot redirect escalation. + +Teardown reports root exit and survivor cleanup independently. It does not claim success merely because the shell exited; disposal resolves only after no captured tree member remains or returns a structured cleanup failure naming the survivors. Service disposal still clears its backend, reservation, and owner-detacher registries when a close fails. It never broadens ownership to every member of the root PID's POSIX session. + +### Composition and rollout + +The example composition remains opt-in and safe by default: + +```yaml +plugins: + '@deepseek-ai/dsh-sandbox-local': + '@deepseek-ai/dsh-sandbox-policy': + config: + mode: workspace-write + workspaceRoot: . + '@deepseek-ai/dsh-pty': + '@deepseek-ai/dsh-pty-local': + config: + scrollbackLines: 10000 + scrollbackMaxBytes: 4194304 + maxReadBytes: 262144 + pollIntervalMs: 50 + exactProbeAfterMs: 150 + idleSilenceMs: 3000 + timeoutMs: 30000 + disposeGraceMs: 3000 + '@deepseek-ai/dsh-tool-pty': +``` + +The package ships concise tool guidance explaining persistent state, owner isolation, uncertain idle results, cleanup, and the preference for existing one-shot tools when interaction is unnecessary. It does not add a global system-prompt recommendation or mount PTY in shipped defaults; dedicated ACP and headless snapshot overlays exercise the opt-in composition. + +### Deferred work + +- Full-screen TUI support, named key sequences, BEL interruption, terminal resize tools, and alternate-screen snapshots require a separately proven model-facing contract. +- Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited. +- Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol. +- Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work. +- Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics. + +## Alternatives considered + +**Replace `bash`, filesystem tools, or task tools with PTY.** Rejected. One-shot tools retain stronger validation, approval, sandbox, output-bound, and replay contracts. PTY is reserved for interactive state. + +**Add persistent mode to `bash`.** Rejected. Returning on readiness rather than process exit, retaining a process tree across calls, and exposing interactive stdin create a different ownership and failure contract. + +**Require native master-fd access from `node-pty`.** Rejected. Its public API exposes no master fd. The local backend instead derives foreground groups and descendants from supported OS process metadata and treats unreadable metadata as a detector miss. + +**Signal every member of the root PID's POSIX session.** Rejected. `node-pty` may expose a helper PID whose session belongs to the launcher, so SID-wide teardown can signal unrelated harness or desktop processes. A PID-identity-fenced descendant tree is narrower and safe by construction. + +**Publish `PtyIdleDetector` as a replaceable registry.** Rejected. Only the local backend needs these platform probes, while remote backends may receive readiness over their own protocol. Backend replacement already provides the necessary extension point. + +**Add a PTY-specific `sleep` tool.** Rejected. `ctx.tasks` already owns bounded waiting, cancellation, completion notices, and model-facing collection. A second general wake mechanism would cross the agent-loop boundary and duplicate that contract. + +**Include TUI sequences and BEL handling.** Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational. + +**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. + +## Verification + +- Per-file coverage pins owner fencing, concurrent reservations, lifecycle cleanup, readiness tiers, sanitizer carry state, UTF-8 bounds, task integration, schemas, and render intents. +- Linux process fixtures cover non-leader and non-main-thread stdin waits, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite. +- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, signals, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. +- A Loader-driven `cordis.yml` test mounts the real three-package composition, while ACP and headless snapshots pin the six schemas, bounded results, error rendering, and terminal/generic cards through opt-in overlays. +- Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface. +- The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification. + +## Consequences + +**Persistent terminal state is available without weakening one-shot tools.** Shell and REPL state can survive tool calls, while `bash`, `read`, `write`, and `edit` retain their narrower validation, approval, and replay contracts. + +**Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model. + +**Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic. + +**A daemonized descendant can leave the captured tree.** A process that reparents before teardown is no longer discoverable from the `node-pty` root. The implementation accepts that cleanup gap instead of risking SID-wide signals to unrelated processes. + +**A shell can cause external side effects.** Session sandboxing and environment scrubbing reduce local exposure but do not undo pushes, API calls, or messages. Deployments that cannot tolerate those effects must omit PTY or add network policy. + +**Process loss destroys terminal state.** In-process sessions do not survive a harness crash or restart, and raw scrollback is not durable. Important work must be committed to files or another durable system. + +**`node-pty` is a native dependency.** Installation, supported Node versions, prebuild availability, and platform behavior require built-artifact smokes on every supported OS. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md new file mode 100644 index 0000000000..86200d70c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -0,0 +1,171 @@ +# Agent Note: 持久化 PTY 会话 + +Status: implemented + +[English](2026-07-16-persistent-pty-sessions.md) | 中文 + +## 问题 + +harness 可以运行前台与后台命令、编辑文件和委派工作,但无法跨工具调用延续一次交互式终端对话。每次 `bash` 前台运行都会启动一个新 shell,因此 shell 内的 cwd、导出变量、虚拟环境激活状态、函数、job control 状态和交互式子进程都会随本次调用结束。 + +这个缺口排除了状态驻留在终端而不是文件中的工作流,例如单步调试 `gdb`、在 Python 或 Node REPL 中探索、驱动 `ed` 这类行式编辑器,或者中断前台命令后回到原 shell。通用的 [`ctx.tasks`](../../../../packages/tasks/README.md) 运行时可以保留后台操作句柄和输出,但不提供交互式 stdin 或终端语义。 + +现有 `bash`、`read`、`write` 和 `edit` 工具仍是有界、可审计操作的可靠默认选项。PTY 是对确实需要终端状态的工作的补充功能,不说明这些工具有缺陷,更不意味着要移除它们。 + +## 决策 + +可选的 `packages/pty/` 功能家族提供由 agent 拥有、持久化且面向行式交互的 PTY 会话。它遵循仓库的 [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md),与现有命令和文件系统工具并存,并且不修改 `agent-loop`。 + +当前实现在 Linux 和 macOS 上支持交互式 shell 与行式 REPL。全屏终端应用、按键序列、BEL 触发的控制流、进程丢失后的会话恢复以及跨 agent 共享会话都明确推迟。 + +### 包拓扑 + +| 包 | 角色 | ctx key | +|---|---|---| +| `dsh-pty` | `PtyService`、branded `PtySessionId`、后端注册表、按 owner 隔离的会话契约和结果类型 | `ctx.pty` | +| `dsh-pty-local` | 基于 [`node-pty`](https://github.com/microsoft/node-pty) 的本地后端、平台进程检查、有界终端缓冲、沙箱解析和进程树监管 | 在 `ctx.pty` 上注册后端 | +| `dsh-tool-pty` | 6 个面向模型的工具、后台发送的 task 运行时集成、使用指引和 ACP render intent | 注册到 `ctx.tools` | + +idle 检测属于后端行为,不是第二条公共 seam。远程或容器后端可能拥有完全不同于本地 `/proc` 检查的权威就绪信号;因此每个 `PtyBackend` 都返回统一的发送结果,同时在内部拥有自己的检测机制。 + +### agent 所有权与身份 + +`PtyService` 在进程内保存活会话,但每个会话都由工具执行上下文传入的确切 `Agent` 拥有。服务铸造不透明的 `PtySessionId`;模型可选填的 `name` 只是显示元数据,仅在该 owner 内唯一。所有操作都以 `sessionId` 为目标,`list`/`read`/`signal`/`kill` 会拒绝 owner 之外的调用方。 + +实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。 + +agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。 + +### 安全与进程边界 + +注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护: + +- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。 +- 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。 + +沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。 + +实现只使用 `node-pty` 的公共功能:子进程 PID、`data` 与 `exit` 通知、`write`、`resize` 和 `kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导前台进程组和父子进程身份。 + +### 6 个面向模型的工具 + +| 工具 | 用途 | 结果 | +|---|---|---| +| `terminal_open` | 从已注册的后端类型创建按 owner 隔离的会话 | `{ sessionId, name, type, motd }` | +| `terminal_send` | 发送文本、可选提交 Enter,并等待就绪或注册一个后台任务 | 有界 viewport、等待状态和会话状态;后台模式还返回 `taskId` | +| `terminal_read` | 从保留的 scrollback 读取一个有界页 | `{ text, totalLines, lineBegin, lineEnd, truncated }` | +| `terminal_signal` | 向当前前台进程组发送一种允许的信号 | `{ delivered, targetPgid }` | +| `terminal_close` | 关闭一个会话并等待进程树静默退出 | `{ killed }` | +| `terminal_list` | 列出调用方的活会话 | 按 owner 隔离的会话摘要 | + +`terminal_send({ sessionId, text, submit?, run_in_background? })` 将 `text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true`。`submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。 + +前台发送返回有界的渲染增量和两个独立事实:`waitReason`(`stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus`(`running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。 + +当 `run_in_background: true` 时,`dsh-tool-pty` 在 `ctx.tasks` 上注册进行中的发送,并立即返回 `taskId`。`task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 将取消转发为 `SIGINT`,只有 PTY 后端拥有的 teardown 路径可以升级信号。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。 + +`terminal_read` 从最新保留行向后分页。后端同时对保留的 scrollback 和完整返回值执行行数与 UTF-8 字节上限,因此单个超长行无法绕过限制。`truncated` 用于区分保留数据丢失与普通 viewport 增量。 + +`terminal_signal` 接受闭合集 `SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP`。后端在执行时解析终端前台进程组。当目标组是顶层 shell 时拒绝 `SIGKILL`,并指引调用方使用 `terminal_close`;进程组解析失败时操作直接失败,而不是向猜测的 PID 发送信号。 + +### 本地就绪检测 + +本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,再执行 3 个有界 fallback 层级。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs` 和 `timeoutMs`。 + +在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 + +macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 + +Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 + +`node-pty` data 通知进入同一个流式 decoder 和终端 parser。parser 的 carry 状态处理跨 chunk 的 UTF-8 与终端查询序列。当前实现只规范化行式输出并检测 alternate-screen 进入,不承诺正确操作全屏应用。 + +### 模型可见输出与持久性 + +现有持久化 `tool/call` 与 `tool/result` 事件是模型发送文本和返回给模型的渲染输出的真源。`terminal_open` 通过已记录的工具结果返回 MOTD;前台 `send`/`read`/`list`/`signal`/`close` 结果走同一路径记录。PTY 包不会把原始字节流重复写入自定义会话事件。 + +后台发送复用现有后台任务完成通知和 `task_output` 结果路径,因此进入后续模型请求的任何输出同样持久化。原始终端字节只作为有界的进程内状态存在,既不持久化也不可恢复。未来的 opt-in transcript sink 必须拥有独立的保留、凭证和隐私契约。 + +### 进程树 teardown + +顶层 `node-pty` 子进程是所有权锚点。关闭时,后端先停止 callback,再按父 PID 以子进程优先顺序捕获该 PID 及其传递子进程、发送 `SIGTERM`、关闭 PTY 并等待静默,然后在可配置的 `disposeGraceMs` 后向已验证的存活者发送 `SIGKILL`,并等待它们离开进程表。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。 + +teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的进程树成员全部消失后才完成,否则返回结构化清理失败并列出存活者。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。所有权绝不会扩大到根 PID 所属 POSIX 会话的全部成员。 + +### 组合与推行 + +示例组合保持 opt-in,并采用安全默认值: + +```yaml +plugins: + '@deepseek-ai/dsh-sandbox-local': + '@deepseek-ai/dsh-sandbox-policy': + config: + mode: workspace-write + workspaceRoot: . + '@deepseek-ai/dsh-pty': + '@deepseek-ai/dsh-pty-local': + config: + scrollbackLines: 10000 + scrollbackMaxBytes: 4194304 + maxReadBytes: 262144 + pollIntervalMs: 50 + exactProbeAfterMs: 150 + idleSilenceMs: 3000 + timeoutMs: 30000 + disposeGraceMs: 3000 + '@deepseek-ai/dsh-tool-pty': +``` + +包提供简洁的工具指引,说明持久状态、owner 隔离、不确定的 idle 结果、清理,以及无需交互时优先使用现有一次性工具。它不增加全局 system prompt 推荐,也不在已发布的默认配置中挂载 PTY;专用 ACP 与 headless 快照 overlay 覆盖 opt-in 组合。 + +### 推迟的工作 + +- 全屏 TUI 支持、命名按键序列、BEL 中断、终端 resize 工具和 alternate-screen 快照需要另行验证面向模型的契约。 +- 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。 +- harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。 +- 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。 +- Windows/ConPTY 支持需要具备 Windows 原生进程所有权与信号语义的后端。 + +## 备选方案 + +**用 PTY 替换 `bash`、文件系统工具或 task 工具。**拒绝。一次性工具拥有更强的校验、审批、沙箱、输出上限和回放契约。PTY 只服务交互式状态。 + +**给 `bash` 增加持久模式。**拒绝。按就绪而不是进程退出返回、跨调用保留进程树、暴露交互式 stdin 会形成不同的所有权和失败契约。 + +**要求从 `node-pty` 获取原生 master fd。**拒绝。它的公共 API 不暴露 master fd。本地后端改为从受支持的 OS 进程元数据推导前台组与子孙进程,并把不可读元数据视为 detector miss。 + +**向根 PID 所属 POSIX 会话的全部成员发送信号。**拒绝。`node-pty` 可能暴露属于启动器会话的 helper PID,因此按 SID 清理可能向无关的 harness 或桌面进程发送信号。带 PID 启动身份校验的子孙进程树范围更窄,其安全边界由结构保证。 + +**发布可替换注册表 `PtyIdleDetector`。**拒绝。只有本地后端需要这些平台 probe,远程后端可能通过自己的协议接收就绪状态。替换后端已经提供所需扩展点。 + +**新增 PTY 专用 `sleep` 工具。**拒绝。`ctx.tasks` 已经拥有有界等待、取消、完成通知和面向模型的收集。第二套通用唤醒机制会跨越 agent loop(智能体循环)边界并重复该契约。 + +**包含 TUI sequence 与 BEL 处理。**拒绝。源 prototype 将这些路径视为 timing-sensitive,且仍记录未解决的 alternate-screen 和交互失败。行式 PTY 已能证明核心价值,无需把未经验证的行为放进基础层。 + +**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前持久 front door 已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 + +## 验证 + +- 每文件覆盖率固定 owner 隔离、并发预留、生命周期清理、就绪层级、sanitizer carry state、UTF-8 上限、task 集成、schema 和 render intent。 +- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 +- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、信号、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。 +- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合;ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果、错误渲染和 terminal/generic card。 +- 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。 +- 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。 + +## 后果 + +**无需削弱一次性工具即可获得持久终端状态。**Shell 与 REPL 状态可以跨工具调用保留,而 `bash`、`read`、`write` 和 `edit` 继续拥有更窄的校验、审批与回放契约。 + +**Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性,有界 timeout、task 等待与信号让模型仍能掌握控制权。 + +**持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。 + +**daemonized 子进程可能离开捕获树。**在 teardown 前 reparent 的进程无法再从 `node-pty` 根进程发现。实现接受这个清理缺口,不冒险按 SID 向无关进程发送信号。 + +**Shell 可以造成外部副作用。**会话沙箱和环境清洗降低本地暴露,但无法撤销 push、API 调用或消息发送。无法容忍这些副作用的部署必须省略 PTY 或增加网络策略。 + +**进程丢失会销毁终端状态。**进程内会话无法跨 harness crash 或 restart 存活,原始 scrollback 也不持久化。重要工作必须提交到文件或其他持久系统。 + +**`node-pty` 是原生依赖。**安装、支持的 Node 版本、prebuild 可用性和平台行为都需要在每个支持 OS 上运行 built-artifact smoke。 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml new file mode 100644 index 0000000000..9c0118a72d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.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-21-cross-session-references.md: bfa015b24cda6c8651829b6a7f0800326da5b502 +2026-07-21-cross-session-references.zh.md: e8e99124f7e2ccfe9fbe97323c17143372017562 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md new file mode 100644 index 0000000000..bfa015b24c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -0,0 +1,60 @@ +# Agent Note: Cross-session references + +Status: implemented + +English | [中文](2026-07-21-cross-session-references.zh.md) + +## Problem + +TUI and ACP users need to bring relevant work from another conversation into one new message without resuming, forking, or granting the source transcript authority over the current session. The harness already exposes exact session enumeration and raw event inspection, but every host independently parsing logs would duplicate compaction folding, provenance filtering, size limits, error behavior, and persistence. Encoding host markup directly into the agent message contract would also bind the core loop to one UI syntax. + +## Decision + +`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]`, call `prepare()` before enqueue, and pass the returned contexts through the generic `SendOptions.contexts` boundary. Core agent packages know only that one queued message may carry frozen `HookContext[]`; they do not parse session URIs or read another log. + +`dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)` and ACP uses standard `resource_link`; text-only clients may use the same inline mention. Explicit Markdown mentions and resource links reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. + +The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: v1 discovery filters only id and cwd, and future title/body search can replace the candidate layer without changing reference identity or preparation. + +## Snapshot and projection + +Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `send()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. + +Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compact`. That marker is part of the compaction capability contract rather than a backend package name. When a source prompt already contains baked prefix context, projection reads only its model-hidden display content, so referencing that target later does not recursively propagate an earlier snapshot. Projection excludes shadowed pre-compaction nodes, tools and results, reasoning, injected context, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. + +One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags. The `## My request:` text is a routing cue rather than the trust boundary: referenced data may spell those words inside a JSON string, but it cannot forge the closing `` tag or escape the data region. The same serializer drives each source's independent byte accounting. The context declares `prompt-prefix` placement, so AgentLoop persists one `user/message` or `steering/message` containing the snapshot, `## My request:` delimiter, and effective direct prompt. Its model-hidden envelope retains the direct display content and source/retention metadata. Target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type or a separate user-role context message. + +## Message ownership + +`send()` and `steer()` snapshot content, resolved source, and contexts together as one deeply frozen lossless-JSON inbox record. Synthetic `inject()` accepts source and model-hidden metadata but not attached contexts, which belong to inbox messages. A claimed ordinary message exposes its attached contexts as the default `agent/prompt-submit` additional contexts; a block writes neither user message nor contexts. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream content and contexts unless it intentionally replaces them. After admission, absent or `separate` placement writes an independent `context/message`, while `prompt-prefix` placement bakes context and the effective request into one prompt event. Drained steering bypasses `agent/prompt-submit` but applies the same placement split. Late steering retains the same record when converted to queued input, while cancellation, disposal, and terminal discard drop message and contexts together. `agent/queued` reports the frozen contexts so the observation event describes the complete retained item. + +This preserves host driving semantics: TUI decides `send()` versus `steer()` from the agent state after preparation, so only its queued path dispatches UserPromptSubmit hooks; ACP continues to call `send()` once per `session/prompt`. Reference preparation is not a new steering protocol and does not create a turn by itself. + +## Host adapters + +TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal. + +ACP detects direct slash commands from ordinary prompt flattening before extracting `dsh-session:` resource links and canonical inline mentions, so URI-shaped command arguments remain opaque while ordinary resource-link rendering is preserved. Standard `session/list` exposes each loadable session's folded title and, when references are mounted, a canonical URI under `_meta["deepseek-harness/sessionReference"]`; a client can use `title ?? sessionId` as the resource-link name. A valid reference without the optional service returns a capability-unavailable RPC error, and preparation failure occurs before the in-flight turn slot and agent send. A preparation-specific abort owner makes `session/cancel` and bridge teardown stop pending reads. Picker UI remains an ACP client responsibility because ACP does not define a cross-session mention menu. + +## Budget and retention + +Each of at most three references is independently capped at 65,536 UTF-8 bytes by default. Retention preserves current compact checkpoints and the newest conversation unit before dropping older non-checkpoint messages. An oversized retained text uses `dsh-retention` head/tail slicing and records exact omitted bytes; if one source's fixed serialized fields cannot fit its cap, the whole preparation fails rather than emitting a partial context. + +## Alternatives considered + +- **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only. +- **Put mention syntax in `Agent.send()`** — rejected because it would make the core protocol parse TUI/ACP presentation and prevent typed non-text hosts from sharing the semantic layer. +- **Implement references inside TUI and ACP separately** — rejected because projection, security warning, retention, and persistence would drift across hosts. +- **Place a separate user-role context message beside the prompt** — rejected because two adjacent user messages weaken the prompt's deictic binding: in `@foo what does this session discuss?`, the model may resolve “this session” as the current conversation instead of the referenced snapshot. +- **Bake the prefix host-side before `send()`** — rejected because `agent/prompt-submit` must inspect and rewrite only the direct prompt. The effective prompt and attached contexts meet only after admission in AgentLoop, which can apply an `allow.content` rewrite consistently to both combined model content and `envelope.displayContent`; earlier host assembly would expose snapshot bytes to the hook or let those two views diverge. +- **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history. +- **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity. +- **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state. + +## Verification + +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, title-aware ACP session listing, ordinary ACP resource links, opaque ACP command arguments, and compact TUI/ACP replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. + +## Consequences + +The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. Standard TUI/ACP demo bundles mount it explicitly and expose its count and per-source byte limits in their own config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md new file mode 100644 index 0000000000..e8e99124f7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -0,0 +1,60 @@ +# Agent Note: 跨会话引用 + +Status: implemented + +[English](2026-07-21-cross-session-references.md) | 中文 + +## 问题 + +TUI 与 ACP(Agent Client Protocol)用户需要把另一场对话中的相关工作带入一条新消息,但不恢复、不 fork,也不让源 transcript(文本记录)对当前会话拥有权威性。harness 已经提供准确的会话枚举与原始事件检查,但若每个宿主都独立解析日志,就会重复实现压缩(compaction)折叠、来源过滤、大小限制、错误行为和持久化。把宿主标记直接编码进 agent(智能体)消息契约,还会让核心循环绑定某一种 UI 语法。 + +## 决策 + +`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,在入队前调用 `prepare()`,再通过通用的 `SendOptions.contexts` 边界传递返回的上下文。核心 agent 包只知道一条排队消息可以携带已冻结的 `HookContext[]`;它们既不解析会话 URI,也不读取其他日志。 + +`dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中,ACP 使用标准 `resource_link`;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记与资源链接会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 + +该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是功能依赖:v1 的候选发现只按 id 和 cwd 过滤;未来的标题或正文搜索可以替换候选层,而无需改变引用标识或准备过程。 + +## 快照与投影 + +准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `send()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 + +投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compact` 导出的规范来源标记的检查点用户消息。该标记属于压缩功能契约的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 + +系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签。`## My request:` 文本只是路由提示,不是信任边界:被引用数据可以在 JSON 字符串中包含这些词,但无法伪造闭合的 `` 标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。该上下文声明 `prompt-prefix` 放置方式,因此 AgentLoop 会持久化一条 `user/message` 或 `steering/message`,其中包含快照、`## My request:` 分隔符和最终生效的直接提示词。其模型不可见封套保留直接显示内容以及来源与保留元数据。因此,目标回放无需新增事件类型或单独的用户角色上下文消息,也能满足「模型可见/日志可重建」不变量。 + +## 消息所有权 + +`send()` 与 `steer()` 会把内容、解析后的来源和上下文一起快照为一条深度冻结、无损 JSON 的收件箱记录。合成的 `inject()` 接受来源和模型不可见的元数据,但不接受附加上下文,因为上下文属于收件箱消息。普通消息被认领后,其附带的上下文会作为 `agent/prompt-submit` 的默认附加上下文公开;提示词被阻止时,系统既不写入用户消息,也不写入上下文。waterfall(瀑布式事件)返回的 allow 结果具有最终权威性,因此监听器包装 `next()` 时会保留下游内容与上下文,除非它有意替换这些值。消息被接纳后,未指定放置方式或指定为 `separate` 时会写入独立的 `context/message`;指定为 `prompt-prefix` 时则会把上下文与最终生效的请求合并写入同一个提示词事件。排空 steering 消息时会绕过 `agent/prompt-submit`,但采用相同的放置方式分流。延迟到达的 steering 转换为排队输入时保留同一条记录;取消、dispose(资源释放)和到达终止态后的丢弃则会同时丢弃消息与上下文。`agent/queued` 会报告已冻结的上下文,使观察事件能够描述完整的保留项。 + +这保留了宿主的驱动语义:TUI 在准备完成后根据 agent 状态决定调用 `send()` 还是 `steer()`,因此只有它的排队路径才会分派 UserPromptSubmit 钩子;ACP 则继续调用 `send()`,每个 `session/prompt` 调用一次。引用准备过程不是新的 steering 协议,本身也不会创建轮次。 + +## 宿主适配器 + +TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 + +ACP 先从普通提示词扁平化结果中检测直接斜杠命令,再提取 `dsh-session:` 资源链接和规范的行内提及标记,因此形如 URI 的命令参数保持不透明,同时保留普通资源链接的渲染方式。标准 `session/list` 会公开每个可加载会话折叠后的标题;挂载会话引用功能时,还会在 `_meta["deepseek-harness/sessionReference"]` 下公开规范 URI。客户端可以使用 `title ?? sessionId` 作为资源链接名称。若引用有效但可选服务未挂载,系统会返回「功能不可用」RPC 错误;准备失败会发生在占用进行中轮次槽位并调用 agent send 之前。引用准备过程单独拥有中止控制权,因此 `session/cancel` 和桥接释放都能停止待处理的读取。选择器 UI 仍由 ACP 客户端负责,因为 ACP 未定义跨会话提及菜单。 + +## 预算与保留策略 + +最多三个引用中的每一个默认独立限制在 65,536 个 UTF-8 字节以内,不设置完整提示词的总预算。保留策略会优先保留当前压缩检查点和最新的对话单元,再丢弃较旧的非检查点消息。若保留文本过大,系统使用 `dsh-retention` 进行首尾切片并记录准确的省略字节数;若某个源的固定序列化字段无法装入其上限,整个准备过程会失败,不会输出部分上下文。 + +## 考虑过的替代方案 + +- **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 +- **把提及标记语法放入 `Agent.send()`**:不予采纳,因为这会迫使核心协议解析 TUI/ACP 的表现层,并阻止带类型的非文本宿主复用同一语义层。 +- **在 TUI 和 ACP 中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 +- **在提示词旁放置单独的用户角色上下文消息**:不予采纳,因为相邻的两条用户消息会削弱提示词的指示语绑定:在 `@foo what does this session discuss?` 中,模型可能把「this session」解析为当前对话,而不是被引用的快照。 +- **在调用 `send()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。最终生效的提示词与附加上下文只有在 AgentLoop 接纳后才汇合;此时 AgentLoop 可以把 `allow.content` 改写一致应用于合并后的模型内容和 `envelope.displayContent`。若由宿主更早组装,就会向该钩子暴露快照字节,或使这两个视图发生偏离。 +- **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 +- **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 +- **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 + +## 验证 + +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失、包含标题信息的 ACP 会话列表、普通 ACP 资源链接、不透明的 ACP 命令参数,以及精简的 TUI/ACP 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。 + +## 后果 + +新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI/ACP 演示组合包会显式挂载它,并在各自的配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml new file mode 100644 index 0000000000..10eddece3c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.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-22-web-bind-address.md: 3332176c0cee940648ad334a44edd30879225503 +2026-07-22-web-bind-address.zh.md: f539fff93628205bf0099d8f23dfd13d14e55ca5 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md new file mode 100644 index 0000000000..3332176c0c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md @@ -0,0 +1,29 @@ +# Agent Note: Explicit web bind address + +Status: implemented + +English | [中文](2026-07-22-web-bind-address.zh.md) + +## Problem + +`dsh web` binds every network interface even when its browser runs on the same machine. Local use therefore exposes an unauthenticated development server without an explicit operator choice, while remote-container and LAN-browser use still needs a supported way to accept non-loopback connections. + +The HTTP carrier also hides the bind address inside `startWebServer()`, so alternate shells cannot state their own network policy at the package boundary. + +## Decision + +`dsh web` binds `127.0.0.1` by default. The CLI accepts `--host 0.0.0.0` as the explicit all-interface mode and rejects other values so its network modes remain a small, deliberate contract. All-interface mode keeps printing the loopback URL and, when available, the first external IPv4 URL. + +`WebServerOptions.host` is required. The HTTP carrier passes that value to `node:http` without supplying a fallback, leaving each shell responsible for its bind policy. Programmatic carrier consumers may select another hostname or address directly. + +## Alternatives considered + +**Keep `0.0.0.0` as the default.** Rejected because ordinary same-machine use does not need network-wide reachability and should not acquire it implicitly. + +**Use a boolean exposure flag.** Rejected because `--host 0.0.0.0` names the resulting socket behavior directly and matches the underlying server option without introducing a second term. + +**Default inside `startWebServer()`.** Rejected because the carrier has multiple possible shells and no basis for choosing their deployment policy. Requiring `host` makes the choice visible at every assembly call. + +## Consequences + +Local `dsh web` starts remain reachable at `http://127.0.0.1:3080`; a browser on another machine must opt in with `dsh web --host 0.0.0.0`. The CLI does not yet expose custom interface addresses or IPv6 modes, while programmatic carrier consumers retain that flexibility. Server tests pin both loopback and all-interface forwarding into the Node listen boundary, and the web smoke continues to exercise the default CLI path. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md new file mode 100644 index 0000000000..f539fff936 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md @@ -0,0 +1,29 @@ +# Agent Note:显式指定 Web 绑定地址 + +Status: implemented + +[English](2026-07-22-web-bind-address.md) | 中文 + +## 问题 + +即便浏览器与服务器运行在同一台机器上,`dsh web` 也会绑定所有网络接口。因此,本地使用会在操作者未明确选择的情况下暴露一个未经身份验证的开发服务器;另一方面,远程容器和局域网浏览器场景仍需要一种受支持的方式来接受非环回连接。 + +HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其他壳层无法在包(package)边界明确表达自己的网络策略。 + +## 决策 + +`dsh web` 默认绑定 `127.0.0.1`。CLI(命令行界面)接受 `--host 0.0.0.0` 作为显式启用的全接口模式,并拒绝其他取值,使网络模式保持为一份规模小、经过审慎限定的契约。全接口模式仍然输出本机环回 URL,并在可用时输出第一个外部 IPv4 URL。 + +`WebServerOptions.host` 为必填项。HTTP 承载层将该值直接传给 `node:http`,不提供回退值,因此每个壳层负责制定自己的绑定策略。以编程方式使用承载层的消费方可以直接选择其他主机名或地址。 + +## 曾考虑的替代方案 + +**保留以 `0.0.0.0` 作为默认值。** 不予采纳,因为普通的同机使用不需要在全网范围内可达,也不应隐式获得这种可达性。 + +**使用布尔型暴露标志。** 不予采纳,因为 `--host 0.0.0.0` 直接说明最终的套接字行为,并与底层服务器选项一致,无需再引入第二套术语。 + +**在 `startWebServer()` 内设置默认值。** 不予采纳,因为承载层可能由多种壳层调用,没有依据替它们选择部署策略。要求传入 `host`,可使每次装配调用都明确作出这一选择。 + +## 后果 + +`dsh web` 的本地启动仍可通过 `http://127.0.0.1:3080` 访问;其他机器上的浏览器必须使用 `dsh web --host 0.0.0.0` 显式启用。CLI 尚未开放自定义接口地址或 IPv6 模式,而以编程方式使用承载层的消费方仍保留这种灵活性。服务器测试将环回模式和全接口模式向 Node 监听边界的传递固定为契约,Web 冒烟测试继续覆盖默认 CLI 路径。 diff --git a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md index 4075d4738a..0629acabb4 100644 --- a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md +++ b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md @@ -15,7 +15,7 @@ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-b - Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*/*']` (explicit globs keep bundling to vendored Cordis and the TypeScript package tree; `workspace: true` would also discover example manifests and non-bundled workspace members). - Shared shape: entry `lib/types/index.js`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ also holds TSC's `lib/types` intermediate tree). The entry was originally `src/index.ts`; the [TSC-first build Agent Note](2026-06-17-ts-build-config.md) later moved tsdown to bundling TSC-emitted JS so TypeScript transform behavior comes from one compiler. - Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape). -- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`. +- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b && tsdown` (the root solution owns the emit graph). ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 8f67b6fc2f..529400b8d7 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -1,7 +1,9 @@ -# Agent Note: TSC-first build and one tsconfig +# Agent Note: TSC-first build and one compiler ownership Status: implemented +> Root project topology (which tsconfig owns which graph) has since moved to a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The tsc-first pipeline decided here is unchanged. + ## Problem The current TypeScript build and typecheck setup had these issues: @@ -28,29 +30,29 @@ In-package relative imports use explicit `.ts` specifiers. `pnpm run build` is a two-stage build: -- Stage 1: `tsc -b tsconfig.build.json` emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. - - The build project uses the project-reference graph that `tsc -b` compiles. For example, root `tsconfig.build.json` references package and vendor tsconfigs. It validates and emits package/vendor build results. +- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. + - The graph is the project-reference graph reachable from the root solution `tsconfig.json` through the two aggregates ([topology](2026-07-22-tsconfig-solution-root-two-aggregates.md)). It validates and emits package/vendor build results. - Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. `tsdown` is no longer the owner of TypeScript compilation or declaration output. -`pnpm run typecheck` runs build mode over the root `tsconfig.json`. -- The root `tsconfig.json` is the single development/typecheck project. It typechecks examples, tests, and scripts with `noEmit`, and validates package/vendor source through references. -- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. -- The root no-emit project disables `rewriteRelativeImportExtensions`; it emits nothing and includes tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. +`pnpm run typecheck` runs the same `tsc -b` graph. +- The aggregates (`tsconfig.host.json`, `tsconfig.client.json`) typecheck examples, tests, and scripts with `noEmit`, and validate package/vendor source through references. +- Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. +- The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. The command orchestration shape is: ```sh pnpm run build: -tsc -b tsconfig.build.json +tsc -b tsdown pnpm run verify-node-next-types: tsx scripts/verify-node-next-types.ts pnpm run typecheck: -tsc -b tsconfig.json +tsc -b ``` `pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. @@ -65,7 +67,7 @@ tsc -b tsconfig.json Build responsibilities are clearer: - Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. -- The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. +- The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. - `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index a46b729a4d..87b1c0847b 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -10,7 +10,7 @@ Aggregate jobs such as documentation synchronization hide long sequential chains ## Decision -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI and `doc-sync`. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. diff --git a/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml index eacf4ef13d..9daf021e6e 100644 --- a/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-web-styling-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-19-web-styling-system.md: c80ef0d56a0e57b38fbb52bd07cbc0f69ec85912 -2026-07-19-web-styling-system.zh.md: 59013a4a950196f3a065ac18415f9b5ed42f3ec3 +2026-07-19-web-styling-system.md: b4d647924ab6ab172cd7a7e2531a10a2a7e62981 +2026-07-19-web-styling-system.zh.md: 01064d4d52b3ed2b179a4795f5113b94480945bd diff --git a/.agents/notes/implemented/process/2026-07-19-web-styling-system.md b/.agents/notes/implemented/process/2026-07-19-web-styling-system.md index c80ef0d56a..b4d647924a 100644 --- a/.agents/notes/implemented/process/2026-07-19-web-styling-system.md +++ b/.agents/notes/implemented/process/2026-07-19-web-styling-system.md @@ -2,7 +2,7 @@ Status: implemented -> Token-system update (2026-07-22): the framework rulings here (CSS Modules + clsx, no component library, no tailwind, tokens-only colors) remain in force, but the two-layer `--bg-*`/`--text-*` token table and its `web-ui/src/style/global.css` home were replaced by the `--dsw-*` static+alias sheets in `packages/client/ui-theme/src/styles/` (dark = `body[data-ds-dark-theme]` override). Current authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15. +> Token-system update (2026-07-22): the framework rulings here (CSS Modules + clsx, no component library, no tailwind, tokens-only colors) remain in force, but the two-layer `--bg-*`/`--text-*` token table and its `web-ui/src/style/global.css` home were replaced by the `--dsw-*` static+alias sheets in `packages/client/ui-theme/src/styles/` (dark = `body[data-ds-dark-theme]` override) — the sheets themselves are the token authority. English | [中文](2026-07-19-web-styling-system.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md b/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md index 59013a4a95..01064d4d52 100644 --- a/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md @@ -2,7 +2,7 @@ Status: implemented -> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)。现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15。 +> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)——样式表本身即 token 权威。 [English](2026-07-19-web-styling-system.md) | 中文 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 6b2f1de1e0..71ea20f6ee 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: db1b47566f5aa089ffcb10d130ecde1851b93112 -2026-07-20-gui-testing-system.zh.md: 691c6baf50c1025a09461effd28ac0f1650fb933 +2026-07-20-gui-testing-system.md: fdd5c7f9d33f9a90ea4afe145265be5fe93e0fc2 +2026-07-20-gui-testing-system.zh.md: 0ae08133742711b87e9155ddc6f3104b757c1b55 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 db1b47566f..fdd5c7f9d3 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 @@ -2,7 +2,7 @@ Status: implemented -> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Current test-system authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18. +> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Component-spec shape follows the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md): feed props directly — the store share comes from `createXXXStore().create()` (the real engine, the sanctioned zero-machinery path), framework hooks are plain stubs; no render machinery, no provider mounting. Slot ownership/registry semantics are tier-2 territory (`runtime` + `ui-slots` suites), not component specs. English | [中文](2026-07-20-gui-testing-system.zh.md) 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 691c6baf50..0ae0813374 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 @@ -2,7 +2,7 @@ Status: implemented -> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。测试体系现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18。 +> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md):props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的零机械路径),框架 hook 用普通桩;无渲染机械、不挂 provider。坑位归属/注册表语义归 2 层地界(`runtime` + `ui-slots` 套件),不归组件 spec。 [English](2026-07-20-gui-testing-system.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 9922018569..aa0516648b 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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-serial-cross-platform-ci-reference.md: ffc1fd5b37bc6c9e3427ee55a55300f93a1292f3 -2026-07-21-serial-cross-platform-ci-reference.zh.md: d7f87916865b83973abe6b0708203618cf536c8e +2026-07-21-serial-cross-platform-ci-reference.md: b795a0aff62c20967d2c85429c0c6115c1b9585d +2026-07-21-serial-cross-platform-ci-reference.zh.md: 223fd9cf20a1d8228cb0c6b1b2f3f95644becae6 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index ffc1fd5b37..b795a0aff6 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -6,7 +6,7 @@ English | [中文](2026-07-21-serial-cross-platform-ci-reference.zh.md) ## Problem -The pull-request workflow reaches its latency targets by scheduling the complete primary Node inventory concurrently inside one larger runner. The optimized scheduler still should not be its own only completeness oracle: a defect in its gate inventory or dependency graph could omit work while the optimized job stays green. +The pull-request workflow consolidates required checks into dedicated Linux and Windows jobs. Those jobs still should not be the only completeness oracle: a defect in their gate inventory or dependency graph could omit work while the required aggregate stays green. Encoding the one-minute non-Windows target and three-minute Windows target as job timeouts creates a separate failure mode. Hosted-runner startup and performance vary, so a correct gate can be cancelled at the target boundary before it emits useful diagnostics. The performance objective needs measurement against GitHub timestamps, while correctness needs enough time to finish. @@ -14,21 +14,21 @@ Reviewers also need a direct answer to a simpler question: what happens when the ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run only the optimized larger-runner and compatibility jobs. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. -Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only the optimized jobs; a master push runs only the three serial references. The one-minute non-Windows and three-minute Windows objectives are evaluated from completed hosted-job timestamps and reported as measurements; they are not `timeout-minutes` values. +Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. A higher-core hosted runner remains a possible future benchmark, but it is not the default: larger runners require organization-owned labels and provisioning, while a reference oracle should remain runnable without repository-external runner configuration. Provisioning one later can change the performance experiment without changing this correctness baseline. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered - **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression. - **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check. -- **Run the serial references on every pull request** - rejected because they deliberately trade wall time and runner consumption for simplicity and are not needed in the fast feedback loop. +- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts. - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism. -- **Run the serial reference on larger runners** - rejected because the reference is the portable fallback for the organization-specific pull-request topology. The fast pull-request path uses provisioned larger runners; the serial master path keeps standard labels. +- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index d7f8791686..223fd9cf20 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -拉取请求工作流通过在一台更大型运行器内并发调度完整的主 Node 门禁清单来达到延迟目标。优化调度器仍不应成为自身唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使优化作业保持绿灯,也可能漏掉部分工作。 +拉取请求工作流将必需检查合并到专用的 Linux 和 Windows 作业中。这些作业仍不应成为唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使必需聚合结果保持绿灯,也可能漏掉部分工作。 将非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标写成作业超时,会引入另一种失败模式。托管运行器的启动时间和性能会波动,因此即使门禁本身正确,也可能在到达目标时间边界时被取消,来不及输出有用的诊断信息。性能目标需要根据 GitHub 时间戳衡量,而正确性验证需要给门禁留足完成时间。 @@ -14,21 +14,21 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求只运行使用更大型运行器的优化作业和兼容性作业。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 -master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行优化作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 +master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。仍可将更高核心数的托管运行器作为未来的基准测试,但不将其设为默认选择:更大型运行器需要组织自有的标签和预配,而参考判定基准应无需仓库外部的运行器配置即可运行。日后完成这类预配,可以改变性能实验而无需改变该正确性基线。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 - **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。 -- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业有意以更长的总耗时和更多运行器用量换取简单性,快速反馈循环不需要它们。 +- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。 - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 -- **在更大型运行器上运行串行参考流程**:不予采纳,因为该参考流程是特定组织拉取请求拓扑的可移植后备方案。快速拉取请求路径使用已预配的更大型运行器;串行 master 路径保留标准标签。 +- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.i18n.yaml new file mode 100644 index 0000000000..b2aa854cf3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.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-22-cordis-tutorial-docs.md: 45abb8f524218ce0b2678606ae62c7bf2dbca00b +2026-07-22-cordis-tutorial-docs.zh.md: cd1a62e2a6e7f2e28bc32109dd67746840f8b8b7 diff --git a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md new file mode 100644 index 0000000000..45abb8f524 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md @@ -0,0 +1,32 @@ +# Agent Note: Tutorial-style Cordis docs under docs/cordis-tutorial + +Status: implemented + +English | [中文](2026-07-22-cordis-tutorial-docs.zh.md) + +## Problem + +The repo documents Cordis at two levels: the condensed [cordis-primer](../../../../docs/cordis-primer.md) states the concepts, and the `docs/user/develop/` pages teach harness plugin authoring against harness services. Neither serves a developer meeting Cordis itself for the first time: the primer assumes the reader already writes plugins, and the develop pages jump straight to `defineTool` without showing how contexts, fibers, services, and dispatch actually behave. There was no path where a reader runs bare Cordis, watches a fiber go PENDING, or sees a waterfall veto happen. + +## Decision + +`docs/cordis-tutorial/` holds a seven-chapter hands-on tutorial (first plugin → lifecycle/effects → services → events → config → composition/HMR → harness tool). Its properties, in decreasing order of load-bearing-ness: + +- **Every transcript is real.** Each chapter's files run in the gitignored `tmp/cordis-tutorial/` scratch directory via `node --import tsx ../../vendor/cordis/bin.js`, and the shown output is what those commands print. The chapter that uses harness packages (`@deepseek-ai/dsh-tools` and `@deepseek-ai/dsh-llm`) runs keylessly. +- **dsh-flavored, not pure Cordis**: later chapters use real harness services and events (`ctx.tools`, `tools/result`) so the tutorial lands the reader inside this repo's actual composition model, per the requesting user's choice. +- **English-only, published to both website locales** through `mirroredPages()` in [website/docs.ts](../../../../website/docs.ts) under a `Cordis 教程` / `Cordis tutorial` section of the develop sidebar — the same pattern as the reference pages, so a Chinese pair can ratchet in later without route changes. +- Code fences compile under `doc-typecheck` except the two fences that import scratch-relative files (`./stats.ts`) or intentionally throw, which carry `ignore-check`. + +## Alternatives considered + +**Under `docs/user/develop/` as paired product docs.** That tier requires en+zh+i18n records in the same PR, roughly doubling the change and coupling every future tutorial edit to a translation. Rejected for the first landing; the mirrored projection keeps the same public visibility. + +**Pure-Cordis tutorial with no harness packages.** Cleaner as framework documentation, but the audience is agent developers extending this harness; ending at `ctx.tools.execute` and `tools/result` teaches the composition they will actually work in. The user chose this explicitly. + +**Extending the primer instead of a new directory.** The primer is a 600-word budgeted concept reference; a multi-chapter walkthrough inside it would break its tier's job (and its budget) rather than complement it. + +## Consequences + +- A runnable introduction to Cordis exercises the loader, fiber states, effects, service injection, all five dispatch-mode contracts, Schemastery validation, and HMR. It demonstrates PENDING dependencies and validation failure; it explains the loader's logged unresolved-entry failure because that boot-time log may not reach a console exporter. +- The tutorial's transcripts pin behavior informally but are not snapshot-gated; if loader or HMR behavior changes, the transcripts drift until a human replays the chapters. The compile gate covers only the code fences. +- The chapters name concrete harness APIs (`ctx.tools.execute`, `CallId`, `tools/result`); renames must update the tutorial like any other doc reference (`verify-md-links` catches file moves, not API prose). diff --git a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md new file mode 100644 index 0000000000..cd1a62e2a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md @@ -0,0 +1,32 @@ +# Agent Note: `docs/cordis-tutorial` 下的 Cordis 实操教程文档 + +Status: implemented + +[English](2026-07-22-cordis-tutorial-docs.md) | 中文 + +## 问题 + +本仓库从两个层面介绍 Cordis:精简的 [cordis-primer](../../../../docs/cordis-primer.md) 阐述概念,`docs/user/develop/` 下的页面则讲解如何基于 harness 服务编写 harness 插件。但二者都不适合初次接触 Cordis 的开发者:primer 假定读者已经会编写插件,开发页面则直接从 `defineTool` 讲起,没有展示上下文、fiber、服务和 dispatch 的实际行为。此前没有一条学习路径让读者运行原生 Cordis、观察 fiber 进入 PENDING 状态,或看到 waterfall(瀑布式事件)否决实际发生。 + +## 决策 + +`docs/cordis-tutorial/` 包含一套七章实操教程(第一个插件 → 生命周期与 effect → 服务 → 事件 → 配置 → 组合与 HMR(热模块替换)→ harness 工具)。以下是教程的特性,按重要性从高到低排列: + +- **每段 transcript(文本记录)都真实可复现。** 每章文件都通过 `node --import tsx ../../vendor/cordis/bin.js` 在 git 忽略的 `tmp/cordis-tutorial/` 临时目录中运行,展示的输出就是这些命令实际打印的内容。使用 harness 包(package)(`@deepseek-ai/dsh-tools` 和 `@deepseek-ai/dsh-llm`)的章节无需密钥即可运行。 +- **采用 dsh 风格,而非纯 Cordis**:后续章节使用真实的 harness 服务和事件(`ctx.tools`、`tools/result`),使读者最终进入本仓库实际采用的组合模型,这遵循了提出请求的用户所作的选择。 +- **仅提供英文版,但发布到网站的两个语言区域**:通过 [website/docs.ts](../../../../website/docs.ts) 中的 `mirroredPages()`,发布到开发侧边栏的 `Cordis 教程` / `Cordis tutorial` 分区。该方式与参考页面采用的模式相同,因此日后可以逐步纳入中文配对,而无需更改路由。 +- 除两个围栏代码块外,其余代码块均通过 `doc-typecheck` 编译;这两个例外分别导入临时目录中的相对路径文件(`./stats.ts`)或有意抛出异常,因此标有 `ignore-check`。 + +## 考虑过的替代方案 + +**作为双语产品文档放在 `docs/user/develop/` 下。** 该层级要求在同一个 PR(Pull Request)中同时提供英文、中文和 i18n 记录,这会使变更量大致翻倍,并要求未来每次修改教程时都同步翻译。首次落地不采用此方案;镜像投影仍可保持同等的公开可见性。 + +**不使用任何 harness 包的纯 Cordis 教程。** 作为框架文档会更简洁,但目标读者是扩展此 harness 的 agent(智能体)开发者;以 `ctx.tools.execute` 和 `tools/result` 收尾,能讲清他们实际使用的组合方式。用户明确选择了此方案。 + +**扩充 primer,而非新建目录。** primer 是一份预算上限为 600 词的精简概念参考;在其中加入多章演练会破坏该文档层级的职责及其篇幅预算,而非形成补充。 + +## 结果 + +- 现在有了一份可运行的 Cordis 入门教程,涵盖 loader、fiber 状态、effect、服务注入、全部五种 dispatch 模式的契约、Schemastery 校验和 HMR。教程实际展示了依赖处于 PENDING 状态和配置校验失败;对于 loader 记录的配置项解析失败,教程只作说明,因为启动阶段的日志可能无法到达控制台导出器。 +- 教程中的 transcript 以非正式方式固定了行为,但没有快照门禁;如果 loader 或 HMR 的行为发生变化,transcript 会逐渐偏离实际结果,直到有人重新运行各章。编译门禁只覆盖围栏代码块。 +- 各章写明了具体的 harness API(`ctx.tools.execute`、`CallId`、`tools/result`);这些 API 重命名时,必须像更新其他文档引用一样同步修改教程(`verify-md-links` 能发现文件移动,但无法发现 API 文字引用变化)。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 6277617a73..6dcafc39dd 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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-evidence-based-larger-hosted-runners.md: c0fae2841f21c431d6416cd5d421929d70197abb -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 51c73a8a631af4f1254c795d09585770fc4e68bb +2026-07-22-evidence-based-larger-hosted-runners.md: c292a4ea49320d684c35d2b9986549d693efb914 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 5e59c787a85bd2093f0c3ceaa8290e7cd42528fa diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index c0fae2841f..c292a4ea49 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -14,11 +14,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand. -Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded: - -- `node 24 / complete` uses one 96-core Linux runner. One checkout, direct selection of the image's preinstalled Node 24 toolcache, pnpm- and ESLint-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 10 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore both caches without saving them, so cache compression and upload do not extend the required job; the master serial reference refreshes those caches outside the pull-request critical path. An uncached exact-head trace put ESLint at 38.11 seconds and coverage at 37.10 seconds, so the small ESLint restore remains useful on the critical path. The read-only job does not persist checkout credentials. -- Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory. -- `windows node 24 / complete` uses one 32-core Windows runner. One preparation wave feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step. +The pools are measurement infrastructure, not a dependency of ordinary pull requests. The [portable required-CI decision](2026-07-23-portable-required-pull-request-ci.md) runs branch-protection jobs on standard GitHub-hosted capacity; `suite=larger-runner-benchmark` compares isolated critical lanes across every provisioned size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. @@ -38,15 +34,15 @@ The same benchmark measured the required Windows build surfaces across every pro Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. -The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head production run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. Production therefore avoids the Windows package-store cache, uses restore-only caches on latency-critical pull-request jobs, and bounds outer concurrency so typecheck, lint, coverage, and build do not oversubscribe one host. +The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head candidate run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. -Three host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. The setup-node action later spent 3.68 seconds printing cached Linux environment details and 46.56 seconds doing the same on Windows after both had already found Node 24.18.0 in the hosted toolcache. The two latency-critical jobs select the newest preinstalled 24.x directory directly, verify its major, and fail loud if the image no longer carries it; compatibility jobs retain setup-node because selecting a non-default runtime is their contract. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. +Host setup remains part of any comparison. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, while `actions/setup-node` spent 46.56 seconds printing cached Windows environment details after finding Node in the hosted toolcache. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. -Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Production therefore retains 16 ESLint workers and admits 10 independent repository gates at once, leaving capacity for the worker pools owned by those gates without starving later independent work. +Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. -Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly five suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. It also includes the pi-ai adapter suite after two hosted aggregate runs delayed an idle-watchdog socket-close observation past its 100-millisecond test deadline. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files. +The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection. -The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs. +Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the portable required path, while larger-runner suites run only by manual dispatch. ## Alternatives considered @@ -54,11 +50,11 @@ The workflow retains two manual measurement suites. `suite=larger-runner-benchma **Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises. -**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. Production uses 96 cores for the shorter controllable critical path; the benchmark suite retains both pools so a sustained image or pricing change can reverse that choice with evidence. +**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. The benchmark suite retains both pools because a sustained image or pricing change can reverse the comparison. **Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it. -**Keep compatibility and Python on standard runners.** Warm standard runs can fit, but runner setup alone has crossed the non-Windows target. Distinct larger pools isolate these environment contracts from that allocation lottery. +**Make larger-runner pools the required default.** This offers lower measured latency when allocation works, but a missing entitlement or delayed organization transfer leaves required jobs queued without repository diagnostics. The portable path accepts longer runtime, and manual suites preserve the performance experiment. **Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process. @@ -66,10 +62,10 @@ The workflow retains two manual measurement suites. `suite=larger-runner-benchma ## Consequences -Primary Node CI has one job, one setup wave, one complete gate inventory, and no shard selectors. Together with two Node compatibility executions, Python, and Windows, production has five paid larger-runner executions instead of seven coarse-lane executions or 49 gate-level executions. +The benchmark topology pays one setup wave per measured aggregate and retains no shard selectors. It runs paid larger-runner executions only when manually dispatched instead of charging every pull request. -GitHub rounds each larger-runner execution up to a whole minute, so eliminating setup waves reduces billed time as well as workflow complexity. The final aggregator remains on a standard runner because it begins only after the paid jobs release capacity. +GitHub rounds each larger-runner execution up to a whole minute, so whole-aggregate measurement exposes both billed time and workflow complexity without making that cost part of branch protection. -The current targets are observed performance contracts, not cancellation deadlines. Exact-head production runs must show every non-Windows job below one minute and the consolidated Windows job below three minutes; manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement. +Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement. -Production CI depends on the organization-owned runner labels in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml). Missing or renamed pools leave jobs queued instead of falling back to standard capacity. All twelve pools remain provisioned so the manual benchmarks can re-evaluate the production size without an administrative setup cycle. +Missing or renamed organization-owned labels leave only manual benchmark jobs queued. All twelve pools remain defined so the benchmark can compare sizes after allocation recovers, while required CI follows the standard-runner fallback. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 51c73a8a63..5e59c787a8 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -14,11 +14,7 @@ Status: implemented 组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。 -生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片: - -- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、直接选择托管映像中预装的 Node 24 toolcache、恢复 pnpm 和 ESLint 缓存以及安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 10 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复这两项缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这两项缓存。一次未使用缓存的分支头精确运行轨迹显示,ESLint 耗时 38.11 秒,覆盖率耗时 37.10 秒,因此在关键路径上恢复这个较小的 ESLint 缓存仍有价值。该只读作业不会持久化代码检出凭据。 -- Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。 -- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一轮准备工作供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。 +这些运行器池是测量基础设施,不是普通拉取请求的依赖。依据[可移植必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),分支保护作业在 GitHub 标准托管容量上运行;`suite=larger-runner-benchmark` 比较每种已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 @@ -38,15 +34,15 @@ Status: implemented Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 -客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的生产运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,生产环境不使用 Windows 包存储缓存,在对延迟敏感的拉取请求作业中使用只恢复不保存的缓存,并限制外层并发度,以免类型检查、lint、覆盖率和构建在同一台主机上过度争用资源。 +客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的候选运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 -3 项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。setup-node action 在 Linux 和 Windows 均已从托管 toolcache 找到 Node 24.18.0 后,仍分别花费 3.68 秒和 46.56 秒输出缓存的环境详情。两个延迟关键作业会直接选择最新的预装 24.x 目录并验证其主版本号;如果映像不再提供该目录,作业会明确报错并失败。兼容性作业仍使用 setup-node,因为选择非默认运行时正是它们的契约。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 +任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上;`actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 -内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,生产环境将 ESLint 工作线程上限维持在 16 个,并且同时最多运行 10 项相互独立的仓库门禁,既为这些门禁自身的工作线程池留出容量,又避免后续独立工作因资源不足而迟迟无法启动。 +内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 -Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 5 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。两次托管聚合运行都将空闲看门狗对套接字关闭的观测延迟到超过其 100 毫秒测试截止时间,因此这份清单还包含 pi-ai 适配器套件。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。 +进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数。 -工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。 +只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用可移植的必需路径,大型运行器套件仅通过手动触发运行。 ## 曾考虑的替代方案 @@ -54,11 +50,11 @@ Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则 **将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。 -**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。生产环境使用 96 核来缩短可控的关键路径;基准测试套件保留两种规格,因此如果映像或定价发生持续性变化,仍可根据证据反转这项选择。 +**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。基准测试套件保留两种规格,因为映像或定价的持续变化可能反转比较结果。 **让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。 -**让兼容性和 Python 继续使用标准运行器。** 标准运行器热运行可以达到目标,但仅运行器设置一项就曾超过非 Windows 目标。不同的大型运行器池可以让这些环境契约免受这种分配波动影响。 +**将大型运行器池设为必需的默认选择。** 分配成功时,该方案能缩短实测延迟,但缺少使用资格或组织转移延迟都会使必需作业持续排队,且不会产生仓库诊断信息。可移植路径接受更长的运行时间,手动套件则保留性能实验。 **将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。 @@ -66,10 +62,10 @@ Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则 ## 后果 -主 Node CI 只有 1 个作业、1 轮设置、1 份完整门禁清单,而且没有分片选择器。加上 2 次 Node 兼容性执行、Python 和 Windows,生产环境共有 5 次付费大型运行器执行,而非 7 次粗粒度通道执行或 49 次门禁级执行。 +基准测试拓扑对每个实测聚合流程只承担 1 轮设置开销,且不保留分片选择器。付费大型运行器仅在手动触发时执行,而不会向每个拉取请求收取这项费用。 -GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此消除设置轮次既能减少计费时长,也能降低工作流复杂度。最终聚合作业仍使用标准运行器,因为它只会在付费作业释放容量后启动。 +GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整聚合测量能同时呈现计费时长与工作流复杂度,而不会让这项成本进入分支保护路径。 -当前目标是基于观测得到的性能契约,而非取消截止时间。分支头精确的生产运行必须表明每个非 Windows 作业都低于 1 分钟,合并后的 Windows 作业低于 3 分钟;当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。 +性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。 -生产 CI 依赖 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 中由组织持有的运行器标签。池缺失或改名会让作业一直排队,不会回退到标准容量。全部 12 个池均保持已预配状态,因此手动基准测试无需再次经过管理配置周期,就能重新评估生产规格。 +组织自有标签缺失或改名时,只有手动基准作业会排队。全部 12 个池均保持已定义状态,因此分配恢复后,基准测试仍可比较各规格,而必需 CI 则使用标准运行器后备路径。 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index a21afc8e6a..361f7a0bd0 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.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-fast-local-git-hooks.md: bab47c6479f1a2c01cbfa7152b1d610917fb6175 -2026-07-22-fast-local-git-hooks.zh.md: 7b279b1a9ad86e09ed5cf7d2470cb61ff17e09b7 +2026-07-22-fast-local-git-hooks.md: a07af1cd424c86f7fa80ea946cd5012362cc66eb +2026-07-22-fast-local-git-hooks.zh.md: 78d4ea8980476609a9140737a75152eba123b308 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md index bab47c6479..a07af1cd42 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -14,7 +14,7 @@ Fast hooks still need to reject cheap, high-confidence defects before work leave [lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: ESLint fixes and re-stages changed JavaScript and TypeScript, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode. -Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The `check:pre-push` package script and `pre-push` scheduler mode do not exist; [scripts/run-gates.ts](../../../../scripts/run-gates.ts) continues to own CI and `doc-sync` scheduling. +Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The opt-in `check:all` package script selects the `check-all` scheduler inventory in [scripts/run-gates.ts](../../../../scripts/run-gates.ts) independently of the hooks; it is a contributor command, not an agent instruction. Agents inspect the outgoing diff and run the narrowest tests and checks that cover its behavior once. CI owns exhaustive coverage, built-artifact checks, and the platform matrix. A complete local rehearsal is reserved for an explicit request, CI diagnosis, or a repository-wide change that cannot be validated credibly by narrower evidence. @@ -31,6 +31,6 @@ This decision supersedes the local-hook portion of [Parallel pre-push gates](202 ## Consequences -Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. +Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index 7b279b1a9a..78d4ea8980 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -14,7 +14,7 @@ agent(智能体)已经会运行能够覆盖自身改动的测试和检查, [lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:ESLint 修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。 -两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。`check:pre-push` 包脚本与调度器的 `pre-push` 模式不存在;[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 继续负责 CI 和 `doc-sync` 调度。 +两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。可选运行的 `check:all` 包脚本独立于这些钩子,从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `check-all` 调度器清单;它是贡献者命令,而非对 agent 的指令。 agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小范围测试和检查。CI 负责全量覆盖率门禁、构建产物检查与平台矩阵。只有在明确要求、诊断 CI,或涉及全仓库的改动无法由范围更窄的证据得到可信验证时,才完整运行一遍本地检查矩阵。 @@ -31,6 +31,6 @@ agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小 ## 结果 -普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 +普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml new file mode 100644 index 0000000000..d191386725 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.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-22-tsconfig-solution-root-two-aggregates.md: 19c229693b98ff3825caf935fa647ab85aff0f56 +2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: becc43de1ef2f6a53b0f6c2285eb64d9b42604f1 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md new file mode 100644 index 0000000000..19c229693b --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md @@ -0,0 +1,45 @@ +# Agent Note: Solution root over two aggregate programs + +Status: implemented + +English | [中文](2026-07-22-tsconfig-solution-root-two-aggregates.zh.md) + +## Problem + +The GUI split introduced a second aggregate program (`tsconfig.client.json`, [layering RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries: + +- The typecheck and build references lists drifted apart (`packages/goal/command-goal` was in the typecheck graph but missing from the build graph). +- The lefthook pre-push hook ran `tsc -b tsconfig.json` only, so client-side type breakage passed the local checkpoint and surfaced in CI. +- tsserver discovers only configs named `tsconfig.json`, so client test files sat on no discoverable config chain and fell back to inferred projects (no paths, wrong lib/jsx). +- The vitest configs pointed at three different resolution sources (`tsconfig.vitest.json`, the root config, and one hand-written alias). + +## Decision + +One solution root, two check units, one shared base pair, no separate build or vitest config: + +| File | Role | Forms a program? | +|---|---|---| +| `tsconfig.json` | Solution root: `extends` base, `files: []`, two references; the whole-repo `tsc -b tsconfig.json` graph, the tsserver entry, and the nearest config for get-tsconfig consumers (tsx running `examples/`, `scripts/`, doc fences) whose bare workspace imports resolve through the inherited `paths` | No | +| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map; doubles as the resolution facade for vite-tsconfig-paths (no `include`, so it applies to every importer) | No | +| `tsconfig.base.client.json` | Browser compiler shape (`jsx: react-jsx`, DOM libs, `types: []`) shared by the client aggregate and every `packages/client/*` package | No | +| `tsconfig.host.json` | The former root aggregate, moved verbatim: host packages, examples, tests, scripts, website; excludes `packages/client` | Yes | +| `tsconfig.client.json` | Client packages and their tests; extends `tsconfig.base.client.json` | Yes | + +The load-bearing principle: **cordis `Context` declaration-merge collisions exist only inside a `ts.Program`, never in module resolution.** A solution file forms no program, so referencing both aggregates from one root cannot collide the merges; vite-tsconfig-paths reads only `paths` and `include` and discards types, so one facade may span both sides. The only way to explode is to flatten both sides into a single program — hence two derived disciplines: `tsconfig.base.json` never gains `include`/`files` (it would leak into every extending package and narrow the facade), and every repo-wide `ts.Program` consumer (`scripts/ts-project.ts`, doc-typecheck standalone mode) seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly, never the root solution. Program-backed generators and semantic gates intentionally stay host-only; the client side gets program-backed gates only when a real need arrives. + +Commands collapse to one graph and keep the config name explicit: `typecheck` = `tsc -b tsconfig.json`, `build` = `tsc -b tsconfig.json && tsdown`, lefthook pre-push stays `tsc -b tsconfig.json --pretty false` unchanged (the same line now covers both sides through the solution). `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`. + +The solution root `extends` the base deliberately: `examples/` and `scripts/` have no nearer tsconfig, so tsx (get-tsconfig) resolves their workspace imports through the root file. `extends` restores the `paths` map there while `files: []` keeps the file program-less. Their *type checking* is unaffected by this: examples, scripts, and website files are included by the host aggregate. + +## Alternatives considered + +- **Rename `tsconfig.build.json` to `tsconfig.host.json`** — rejected: the build graph was the full emit graph including all client packages, not a host graph; the name that fits the former root aggregate is `tsconfig.host.json`, and the build graph itself is subsumed by the solution. +- **Point vitest at the root solution** — rejected: a solution has neither `paths` nor `include`, so resolution would become a function of how far the plugin walks references, and the client aggregate's include (tests only, no src) would leave transitive src→src imports unmapped, falling through to `exports` and loading a second copy of module singletons. +- **Keep `tsconfig.vitest.json` as a dedicated facade** — retained only as the fallback if vite-tsconfig-paths mishandles an include-less config; the base file already carries the paths map, and an include-less config applies everywhere, which is strictly wider than the facade's hand-kept include list. + +## Consequences + +- `docs/development.md#typescript-project-layout` is the authoritative description; root `AGENTS.md` carries the two disciplines as conventions. +- The [ts-build-config note](2026-06-17-ts-build-config.md) keeps ownership of the tsc-first build pipeline (tsc emits, tsdown bundles, `.ts` specifiers with `rewriteRelativeImportExtensions`); its former "one root typecheck project" shape is superseded by this note. +- Adding a package registers it in exactly one aggregate's references (host packages in `tsconfig.host.json`, client packages in `tsconfig.client.json`); the build graph needs no separate registration. +- The build gate depends on the typecheck gate: both now drive the same `tsc -b` graph, so running them concurrently would race the same `.tsbuildinfo` files. diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md new file mode 100644 index 0000000000..becc43de1e --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 以 solution 根文件统辖两个聚合 program + +Status: implemented + +[English](2026-07-22-tsconfig-solution-root-two-aggregates.md) | 中文 + +## 问题 + +GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称: + +- 类型检查与构建的 references 列表逐渐脱节(`packages/goal/command-goal` 在类型检查图里,构建图里却没有)。 +- lefthook 的 pre-push 钩子只运行 `tsc -b tsconfig.json`,客户端侧的类型破坏因此通过本地检查点,直到 CI 才暴露。 +- tsserver 只发现名为 `tsconfig.json` 的配置,客户端测试文件不在任何可发现的配置链上,回落到推断项目(inferred project),既没有 paths,lib/jsx 也不对。 +- 各 vitest 配置指向三个不同的解析来源(`tsconfig.vitest.json`、根配置,外加一处手写别名)。 + +## 决策 + +一个 solution 根文件,两个检查单元,一对共享 base,不再单设 build 或 vitest 配置: + +| 文件 | 角色 | 是否构成 program? | +|---|---|---| +| `tsconfig.json` | solution 根文件:`extends` base、`files: []`、两条 references;同时是全仓 `tsc -b tsconfig.json` 图、tsserver 入口,以及 get-tsconfig 消费方(tsx 运行 `examples/`、`scripts/`、文档围栏代码块)就近命中的配置,其裸 workspace 导入经继承来的 `paths` 解析 | 否 | +| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射;兼任 vite-tsconfig-paths 的解析门面(不含 `include`,因此对每个导入方都生效) | 否 | +| `tsconfig.base.client.json` | 浏览器侧编译形态(`jsx: react-jsx`、DOM lib、`types: []`),由客户端聚合与每个 `packages/client/*` 包共享 | 否 | +| `tsconfig.host.json` | 原根聚合原样迁入:宿主各包、examples、测试、scripts、website;排除 `packages/client` | 是 | +| `tsconfig.client.json` | 客户端各包及其测试;通过 `extends` 继承 `tsconfig.base.client.json` | 是 | + +整个方案立足的原则:**cordis `Context` 的声明合并冲突只存在于同一个 `ts.Program` 内部,从不发生在模块解析中。** solution 文件不构成 program,因此从一个根文件同时引用两个聚合不会让两侧的声明合并相撞;vite-tsconfig-paths 只读取 `paths` 与 `include`、丢弃全部类型信息,因此一个门面可以横跨两侧。唯一会爆炸的做法是把两侧压平进同一个 program,由此推出两条派生纪律:`tsconfig.base.json` 永远不得添加 `include`/`files`(否则会泄漏进每个继承它的包,并收窄门面范围);每个全仓级 `ts.Program` 消费方(`scripts/ts-project.ts`、doc-typecheck 独立模式)都显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子,绝不使用根 solution。基于 program 的生成器与语义门禁有意只留在宿主侧;客户端侧只有在真实需求出现时才引入基于 program 的门禁。 + +各命令收敛到一张图,且显式写出配置名:`typecheck` = `tsc -b tsconfig.json`,`build` = `tsc -b tsconfig.json && tsdown`,lefthook pre-push 保持 `tsc -b tsconfig.json --pretty false` 不变(经由 solution,这同一行命令现已覆盖两侧)。`tsconfig.build.json` 与 `tsconfig.vitest.json` 删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。 + +solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更近的 tsconfig,tsx(get-tsconfig)通过根文件解析它们的 workspace 导入。`extends` 把 `paths` 映射带回根文件,`files: []` 则让它始终不构成 program。这不影响两者的*类型检查*:examples、scripts 与 website 的文件由宿主聚合纳入。 + +## 考虑过的替代方案 + +- **把 `tsconfig.build.json` 改名为 `tsconfig.host.json`**——不予采纳:构建图是包含全部客户端包的全量 emit 图,不是宿主图;`tsconfig.host.json` 这个名字对应的是原根聚合,而构建图本身已被 solution 吸收。 +- **让 vitest 指向根 solution**——不予采纳:solution 既没有 `paths` 也没有 `include`,解析结果将取决于插件沿 references 走多远;且客户端聚合的 include 只收测试、不收 src,传递的 src→src 导入会失去映射,回落到 `exports`,加载出模块单例的第二份副本。 +- **保留 `tsconfig.vitest.json` 作为专用门面**——仅保留为后备方案:若 vite-tsconfig-paths 处理不了无 include 的配置再启用;base 文件已经携带 paths 映射,而无 include 的配置处处生效,严格宽于该门面手工维护的 include 列表。 + +## 后果 + +- `docs/development.md#typescript-project-layout` 是权威描述;根 `AGENTS.md` 以约定形式收录上述两条纪律。 +- [ts-build-config Agent Note](2026-06-17-ts-build-config.md) 继续拥有 tsc 先行的构建流水线(tsc 负责输出,tsdown 负责打包,`.ts` 说明符配合 `rewriteRelativeImportExtensions`);其原先「单一根类型检查项目」的形态由本文取代。 +- 新增一个包只登记进恰好一个聚合的 references(宿主包进 `tsconfig.host.json`,客户端包进 `tsconfig.client.json`);构建图无需另行登记。 +- 构建门禁依赖类型检查门禁:两者现在驱动同一张 `tsc -b` 图,并发运行会在同一批 `.tsbuildinfo` 文件上竞态。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml new file mode 100644 index 0000000000..463777eacf --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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-23-portable-required-pull-request-ci.md: a430d43f7cb3dd4df987d35f3a49d130c397f8e3 +2026-07-23-portable-required-pull-request-ci.zh.md: cbd5d150056f77e52105f56c70a1ead74f052f59 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md new file mode 100644 index 0000000000..a430d43f7c --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -0,0 +1,35 @@ +# Agent Note: Portable required pull-request CI + +Status: implemented + +English | [中文](2026-07-23-portable-required-pull-request-ci.zh.md) + +## Problem + +Required pull-request jobs assigned to organization-owned runner labels remain queued when GitHub cannot allocate those pools. The workflow is valid and standard GitHub-hosted jobs can still pass, but `all checks passed` never starts and an otherwise healthy pull request cannot satisfy branch protection. + +Billing health, a runner definition's `Ready` state, and a large autoscaling ceiling do not prove that a named pool can receive a job. Required correctness checks need a portable execution path that does not depend on repository-external runner provisioning. + +## Decision + +[CI](../../../../.github/workflows/ci.yml) runs every required pull-request job on GitHub's standard `ubuntu-latest` or `windows-2025` capacity. The primary Node and Windows jobs keep their complete consolidated inventories, while top-level gates, coverage, ESLint, publint, and snapshot replay use one worker on the smaller hosts. Node versions are selected through `actions/setup-node`, and the Windows job enables Developer Mode before installing the symlinked workspace. + +The `node 24 / complete`, Node compatibility, Python SDK, and `windows node 24 / complete` jobs remain dependencies of `all checks passed`; no gate is removed or made observational to recover availability. Branch protection continues to require `e2e` and `all checks passed`. + +The two manual larger-runner suites and all twelve organization-owned labels remain available for measurement. They do not participate in ordinary pull requests. The [larger-runner measurements](2026-07-22-evidence-based-larger-hosted-runners.md) remain evidence for future performance work, while the [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent master-push completeness check. + +## Alternatives considered + +**Wait for organization-runner allocation to recover.** A queue with no assigned runner emits no repository diagnostic and can block every pull request indefinitely, so an external recovery is not a correctness path. + +**Use only the smallest organization-owned pools.** Every named pool crosses the same organization allocation boundary; reducing core count does not remove the dependency that caused the queue. + +**Skip or demote checks while capacity is unavailable.** This would make the status green by dropping evidence rather than by running the repository's required contracts. + +**Keep larger-host worker limits on standard runners.** Concurrent full-repository gates and their inner worker pools can oversubscribe the smaller memory and CPU allocation, turning an availability repair into contention failures. + +## Consequences + +Ordinary pull requests can acquire runners without organization-specific configuration, and a live exact-head run proves the same commands that branch protection consumes. The trade-off is longer elapsed time and more rounded standard-runner minutes than the measured larger-runner topology. + +Manual larger-runner benchmarks can remain queued without blocking pull requests. Restoring larger runners to the required path needs a separate evidence-based decision after exact-head jobs receive nonzero runner IDs and complete reliably; changing a definition's status alone is insufficient. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md new file mode 100644 index 0000000000..cbd5d15005 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 可移植的拉取请求必需 CI + +Status: implemented + +[English](2026-07-23-portable-required-pull-request-ci.md) | 中文 + +## 问题 + +分配到组织自有运行器标签的拉取请求必需作业,在 GitHub 无法为这些池分配运行器时会持续排队。工作流本身有效,GitHub 标准托管作业仍能通过,但 `all checks passed` 始终无法启动,原本健康的拉取请求因此无法满足分支保护要求。 + +账单状态正常、运行器定义处于 `Ready` 状态以及较高的自动扩缩容上限,都不能证明指定的运行器池可以接收作业。必需的正确性检查需要一条可移植的执行路径,且该路径不能依赖仓库外部的运行器预配。 + +## 决策 + +[CI](../../../../.github/workflows/ci.yml) 在 GitHub 标准的 `ubuntu-latest` 或 `windows-2025` 容量上运行每项拉取请求必需作业。主 Node 作业和 Windows 作业保留各自完整的合并清单,而顶层门禁、覆盖率、ESLint、publint 和快照回放在这些较小的主机上均使用 1 个工作线程。Node 版本通过 `actions/setup-node` 选择;Windows 作业会在安装采用符号链接的工作区前启用开发人员模式。 + +`node 24 / complete`、Node 兼容性、Python SDK 和 `windows node 24 / complete` 作业继续作为 `all checks passed` 的依赖项;为恢复可用性,不会移除任何门禁,也不会将其降为观测性检查。分支保护继续要求 `e2e` 和 `all checks passed`。 + +两项手动大型运行器套件和全部 12 个组织自有标签继续用于测量,但不参与普通拉取请求。[大型运行器测量结果](2026-07-22-evidence-based-larger-hosted-runners.md)继续作为后续性能工作的证据,[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则继续作为 master 推送时独立的完整性检查。 + +## 曾考虑的替代方案 + +**等待组织运行器恢复分配。** 未分配运行器的队列不会产生仓库诊断信息,而且可能无限期阻塞每个拉取请求,因此依赖外部恢复不能构成正确性路径。 + +**仅使用最小的组织自有运行器池。** 每个指定的运行器池都需要经过相同的组织分配边界;减少核心数不能消除导致作业排队的依赖。 + +**在容量不可用时跳过检查或降低其级别。** 这种方式通过丢弃证据而非执行仓库的必需契约来使状态变绿。 + +**在标准运行器上保留大型主机的工作线程上限。** 完整仓库门禁及其内层工作线程池并发运行时,可能超出较小主机的内存和 CPU 配额,使可用性修复变成资源争用故障。 + +## 后果 + +普通拉取请求无需组织专有配置即可获得运行器,一次实际的分支头精确运行能够证明分支保护使用的同一组命令。代价是,与实测的大型运行器拓扑相比,总耗时更长,而且按整分钟计费的标准运行器用量更多。 + +手动大型运行器基准测试可以继续排队,而不会阻塞拉取请求。要将大型运行器恢复为必需路径,需要在分支头精确作业获得非零运行器 ID 并可靠完成后,另行作出基于证据的决策;仅改变运行器定义的状态还不够。 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index f400701fcd..ebc48ba183 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -18,6 +18,8 @@ A snapshot test boots the real ACP example, drives its stdio protocol from a det Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` events reproduce the model streams; tool, message, and boundary events capture the harness behavior. One ordinary session artifact therefore serves as both replay source and behavioral expected output. +When a scenario pins an alternative physical storage layout, its fixture is mechanically derived from a real unpacked counterpart. The scenario test requires every intended storage-row kind and exact event-for-event equality after decoding before the ordinary replay and log comparison proves that the assembled process consumes and reproduces that layout. + ### Replay derives the model script from the log `llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` groups recorded chunks by `(turn, step)` and serves one group per model call. The loop makes one stream call per step, so the grouping is exact and includes error finish chunks without special handling. @@ -57,7 +59,7 @@ Normalization replaces session, cwd, protocol-id, timestamp, path, and process v ### Isolation: normalization now, sandbox later -Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. Concurrent replay runs own separate cwd, persistence, and fixed-length scenario-keyed spill roots, so one scenario's teardown cannot delete another's in-flight full-output recovery while real-path preview budgets remain stable. This tier does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. +Tool determinism comes from a generated cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. The cwd defaults to the platform temp directory; a scenario can instead supply its parent when temp is an always-writable policy root and the behavior needs an independent project location. Concurrent replay runs own separate cwd, persistence, and fixed-length scenario-keyed spill roots, so one scenario's teardown cannot delete another's in-flight full-output recovery while real-path preview budgets remain stable. This tier does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. ### The replay plugin is its own package @@ -75,6 +77,6 @@ Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-log ## Consequences -The new tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the temporary cwd for both record and replay. In return the tier provides deterministic keyless transcript coverage through the real Loader and tool composition. The subprocess, input, workspace, normalization, and replay harness can support examples beyond ACP. +The new tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless transcript coverage through the real Loader and tool composition. The subprocess, input, workspace, normalization, and replay harness can support examples beyond ACP. This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas snapshot tests pin the *external protocol output*. They are complementary — one guards the event-sourcing invariant, the other guards the editor-facing contract. diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index aadeaeea30..8d6032d1cc 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -18,7 +18,7 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su **`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions. -**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). A scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are its ordered primary/child inventory, so the scenario table declares policy without duplicating a child count. The pinned-header contract ([pinned-header Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.expected.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`sessionFixtureNames`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage. +**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative because their boundaries are replay behavior. A scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are its ordered primary/child inventory, so the scenario table declares policy without duplicating a child count. The pinned-header contract ([pinned-header Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.expected.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`sessionFixtureNames`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage. ## Alternatives considered diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md index fa544d6ddb..f9286aeefd 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md @@ -6,13 +6,13 @@ Status: proposed Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, and Knip overrides. Most restate package layout, manifest data, or aggregate command contents. Each new package therefore creates avoidable synchronization points. -The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). +The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly the aggregate configs' (`tsconfig.host.json`, `tsconfig.client.json`) project `references`, which TypeScript requires as explicit arrays (no wildcard form). Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data or layout facts that already exist in `package.json`, workspace globs, or the package hierarchy. ## Proposal -Make the remaining package/gate inventories discoverable. A single canonical source — the `packages//` hierarchy plus package manifests — should drive `tsconfig.build.json`'s `references`, the module graph, and any other full-package list, with a generate-and-verify step (the existing `gen-module-graph` / `gen-cordis-catalog` pattern: a generator writes the artifact, a `--check` mode in `hygiene`/`doc-sync` fails on a stale committed copy). Module graph generation already reads package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. +Make the remaining package/gate inventories discoverable. A single canonical source — the `packages//` hierarchy plus package manifests — should drive the aggregates' `references`, the module graph, and any other full-package list, with a generate-and-verify step (the existing `gen-module-graph` / `gen-cordis-catalog` pattern: a generator writes the artifact, a `--check` mode in `hygiene`/`doc-sync` fails on a stale committed copy). Module graph generation already reads package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. @@ -20,7 +20,7 @@ One cataloged item needs no generator at all: folding the e2e entry glob into kn ## Acceptance criteria -- `tsconfig.build.json` project `references` are generated from the hierarchy (a generator emits them; a `--check` gate fails when the committed copy is stale), rather than hand-maintained. +- Aggregate-config project `references` are generated from the hierarchy (a generator emits them; a `--check` gate fails when the committed copy is stale), rather than hand-maintained. - Adding a package does not require editing a static package list for any gate. - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f52274cddd..e9d5630d33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,11 @@ env: jobs: - # One large runner pays hosted setup once, then the repository scheduler - # overlaps the complete unsharded primary Node inventory. Build starts eagerly; - # only consumers of emitted output wait for it. + # One enterprise runner pays setup once, then executes the complete + # unsharded primary Node inventory with repository-level concurrency. node-24: if: github.event_name == 'pull_request' - runs-on: dsh-ubuntu-24-04-96core + runs-on: dsh-enterprise-ubuntu-24-04-32core-test name: node 24 / complete env: DSH_COVERAGE_MAX_WORKERS: '16' @@ -58,20 +57,16 @@ jobs: - uses: actions/cache/restore@v4 with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Select preinstalled Node, install dependencies, and prepare bubblewrap + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack, install dependencies, and prepare bubblewrap run: | - node_root="$(printf '%s\n' "$RUNNER_TOOL_CACHE"/node/"${PRIMARY_NODE_VERSION}".*/x64 | sort -V | tail -n 1)" - if [[ ! -d "$node_root" ]]; then - echo "preinstalled Node ${PRIMARY_NODE_VERSION}.x not found in $RUNNER_TOOL_CACHE" >&2 - exit 1 - fi - echo "$node_root/bin" >> "$GITHUB_PATH" - export PATH="$node_root/bin:$PATH" - [[ "$(node --version)" == "v${PRIMARY_NODE_VERSION}."* ]] corepack enable pnpm install --frozen-lockfile & install_pid=$! @@ -90,8 +85,7 @@ jobs: node-compat: if: github.event_name == 'pull_request' - # Distinct larger-runner pools avoid both standard-runner setup outliers and - # delayed allocation when independent environment contracts share one pool. + # Each compatibility contract receives an independent standard hosted job. runs-on: ${{ matrix.runner }} name: ${{ matrix.name }} env: @@ -103,12 +97,12 @@ jobs: include: - node: '22.19' name: node 22.19 - runner: dsh-ubuntu-24-04-4core - gate_concurrency: '2' + runner: ubuntu-latest + gate_concurrency: '1' - node: 26 name: node 26 - runner: dsh-ubuntu-24-04-32core - gate_concurrency: '2' + runner: ubuntu-latest + gate_concurrency: '1' steps: - uses: actions/checkout@v6 @@ -137,7 +131,7 @@ jobs: python-sdk: if: github.event_name == 'pull_request' - runs-on: dsh-ubuntu-24-04-8core + runs-on: ubuntu-latest name: python 3.10 / keyless SDK steps: - uses: actions/checkout@v6 @@ -158,11 +152,9 @@ jobs: # from observational gates without allowing them to fail the required job. windows: if: github.event_name == 'pull_request' - runs-on: dsh-windows-2025-32core + runs-on: dsh-enterprise-windows-2025-32core-test name: windows node 24 / complete env: - # Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on - # this image. The outer scheduler still overlaps lint with the other gates. DSH_COVERAGE_MAX_WORKERS: '12' DSH_ESLINT_CACHE: '1' DSH_GATE_CONCURRENCY: '16' @@ -173,31 +165,25 @@ jobs: - uses: actions/cache/restore@v4 with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - # Extracting the many-file pnpm store cache is slower on this image than - # a clean parallel install, and saving it adds more latency after gates. - - name: Select preinstalled Node and install (immutable) + - name: Enable Developer Mode (symlink support) + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + # Extracting the many-file pnpm store cache is slower than a clean install, + # and saving it adds more latency after gates. + - name: Enable corepack and install (immutable) shell: pwsh run: | - $nodeRoot = Get-ChildItem -Path "$env:RUNNER_TOOL_CACHE\node" -Directory | - Where-Object { $_.Name -like "$env:PRIMARY_NODE_VERSION.*" } | - Sort-Object { [version]$_.Name } | - Select-Object -Last 1 - if ($null -eq $nodeRoot) { - throw "preinstalled Node $env:PRIMARY_NODE_VERSION.x not found in $env:RUNNER_TOOL_CACHE" - } - $nodeBin = Join-Path $nodeRoot.FullName 'x64' - if (-not (Test-Path $nodeBin -PathType Container)) { - throw "preinstalled Node x64 directory not found at $nodeBin" - } - Add-Content -Path $env:GITHUB_PATH -Value $nodeBin - $env:PATH = "$nodeBin;$env:PATH" - if ((node --version) -notlike "v$env:PRIMARY_NODE_VERSION.*") { - throw "selected unexpected Node version $(node --version)" - } corepack enable pnpm install --frozen-lockfile @@ -237,7 +223,7 @@ jobs: - uses: actions/cache@v4 with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- @@ -309,7 +295,7 @@ jobs: - uses: actions/cache@v4 with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- @@ -525,7 +511,7 @@ jobs: if: matrix.platform == 'linux' with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- diff --git a/AGENTS.md b/AGENTS.md index 739bdfafe2..8007f32b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// prompt/ workspace instructions llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools + pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools lsp/ language-server seam + local stdio provider + model-facing lsp tool skill/ skill provider registry + local impl + catalog/loader tool @@ -99,6 +100,8 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent. - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **Trust TypeScript at typed same-process seams.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. +- **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). +- **`ts.Program` consumers seed `tsconfig.host.json` or `tsconfig.client.json`, never the root solution** — one program holding both sides collides the cordis `Context` merges ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. diff --git a/README.i18n.yaml b/README.i18n.yaml index 020593bf20..41a7a06404 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 8b34d6177834e1c410b2c3ecaf32154de42520b4 -README.zh.md: cf029cc0bb8c5aa527d14753803eac4c28ab9de7 +README.md: 27774fd3e0ffc821e7287f6153906a5d21530dc2 +README.zh.md: b7d08f2bc948d0a6d388dd672c5a702bc7da6f6f diff --git a/README.md b/README.md index 8b34d61778..27774fd3e0 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,76 @@ English | [中文](README.zh.md) -The **DeepSeek Harness SDK** is a plugin-based SDK for building agent harnesses. +DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK. + +It uses an architecture where **everything is a plugin**. ## Install -Install the `dsh` coding agent with one line — it needs `git` and Node `^22.19 || >=24`, and offers to install `pnpm` if it is missing: +Install `dsh` with one command: ```sh curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -It clones the harness to `~/.dsh/source`, runs `pnpm install`, symlinks `dsh` into `~/.local/bin` (offering to add it to your PATH), prompts once for your `DEEPSEEK_API_KEY`, and launches `dsh`; re-running it updates an existing checkout. Run from inside a checkout (`sh scripts/install.sh`) it reuses that checkout and skips the clone. The overridable `DSH_*` variables are documented in [`scripts/install.sh`](scripts/install.sh). +The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key. + +The installer clones DeepSeek Harness to `~/.dsh/source`, links `dsh` into `~/.local/bin`, and launches it. Re-running the command updates the checkout. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. + +## Use DeepSeek Harness + +### Web UI + +For the recommended local interface, build the frontend after installation and after each update, then start the Web UI: + +```sh +pnpm --dir ~/.dsh/source run build:web +dsh web +``` + +The Web UI is served at `http://127.0.0.1:3080` by default. + +### TUI + +Start the full-screen terminal interface: + +```sh +dsh +``` + +### Headless + +Run one task, print the final answer, and exit: + +```sh +dsh -p "summarize this workspace" +``` + +## Why DeepSeek Harness + +Built-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode. + +- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design. +- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode). +- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md). + +## Community + +Follow DeepSeek Harness on Twitter for project updates. ## Development -This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) framework (vendored as source under `vendor/`), microkernel-style: everything is a plugin. - ```sh pnpm install -pnpm run test # vitest -# Agent demos require DEEPSEEK_API_KEY. -pnpm run demo:tui # full-screen TUI coding agent -pnpm run demo:headless "task" # one-shot coding agent -pnpm run demo:cordis # self-referential agent demo -pnpm run demo:acp # ACP server agent demo +pnpm run test:coverage ``` -For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) and [documentation graph index](docs/graph-atlas.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/). +Start with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages. For agents, follow [AGENTS.md](AGENTS.md). + +DeepSeek Harness is currently pre-release. + +## License + +[BSD 3-Clause](LICENSE) diff --git a/README.zh.md b/README.zh.md index cf029cc0bb..b7d08f2bc9 100644 --- a/README.zh.md +++ b/README.zh.md @@ -2,32 +2,80 @@ [English](README.md) | 中文 -**DeepSeek Harness SDK** 是用于构建 agent harness(智能体框架)的 SDK,采取基于插件的设计。 +DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。 + +它采用了**一切皆插件**的架构。 ## 安装 -一行命令即可安装 `dsh` 编码智能体——需要 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时会询问是否代为安装: +使用一条命令安装 `dsh`: ```sh curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -脚本会把 harness 克隆到 `~/.dsh/source`,运行 `pnpm install`,把 `dsh` 软链接到 `~/.local/bin`(并询问是否加入 PATH),提示输入一次 `DEEPSEEK_API_KEY`,随后启动 `dsh`;再次运行会更新已有的检出。若在检出目录内运行(`sh scripts/install.sh`),脚本会复用当前检出并跳过克隆。可覆盖的 `DSH_*` 变量见 [`scripts/install.sh`](scripts/install.sh)。 +安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。 + +安装器会将 DeepSeek Harness 克隆到 `~/.dsh/source`,把 `dsh` 链接到 `~/.local/bin`,然后启动它。再次运行该命令会更新源码目录。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 + +## 使用 DeepSeek Harness + +### Web UI + +推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI: + +```sh +pnpm --dir ~/.dsh/source run build:web +dsh web +``` + +Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 + +### TUI + +启动全屏终端界面: + +```sh +dsh +``` + +### Headless + +运行一项任务,打印最终答案后退出: + +```sh +dsh -p "summarize this workspace" +``` + +## 为什么选择 DeepSeek Harness + +内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。 + +- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。 +- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。 +- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。 + +## 社区 + +扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。 + +

+ DeepSeek Harness 微信社区二维码 +

## 开发 -本 monorepo 基于 [Cordis](https://github.com/cordiverse/cordis) 框架构建(以源码形式收录在 `vendor/` 下),采用微内核风格:所有功能都以插件形式提供。 - ```sh pnpm install -pnpm run test # vitest -# Agent demos require DEEPSEEK_API_KEY. -pnpm run demo:tui # full-screen TUI coding agent -pnpm run demo:headless "task" # one-shot coding agent -pnpm run demo:cordis # self-referential agent demo -pnpm run demo:acp # ACP server agent demo +pnpm run test:coverage ``` -面向开发者:先读[开发指南](docs/development.md),了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)和[文档关系图索引](docs/graph-atlas.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 +请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。 面向 agent:遵循 [AGENTS.md](AGENTS.md)。 + +DeepSeek Harness 目前处于预发布阶段。 + +## 许可证 + +[BSD 3-Clause](LICENSE) diff --git a/apps/cli/README.md b/apps/cli/README.md index b8ff616d59..87f5e670e3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,6 +1,6 @@ # `@deepseek-ai/dsh` -The `dsh` command-line entry, following the `apps/` assembly tier proposed by the `dsh web` PR (#443): `apps/*` are product assemblies over `packages/*` libraries. This branch ships one surface — plain `dsh [config.yml]` boots the interactive TUI coding agent — and reserves the `web` and `-p`/`--prompt` subcommands for that PR so the dispatch merges as a union. +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 TUI surface: @@ -10,6 +10,8 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +The Web surface treats its invoking directory as the default project and loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget. + ## Install (developer machine) Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index dc1fa192a1..303bac61f8 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -78,7 +78,12 @@ export async function runHeadless(argv: string[]): Promise { } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ boot: { persistenceRoot: './.sessions' } }) + const host = await startHost({ + boot: { + persistenceRoot: './.sessions', + workspaceContext: false, + }, + }) const api = new InProcessApiClient(host.handler) const created = await unwrap(await api.sessions.create({}), () => host.dispose()) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 5e03f2bb19..66a99bb577 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -10,20 +10,38 @@ import { createRequire } from 'node:module' import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime' import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver' +const LOOPBACK_HOST = '127.0.0.1' +const ALL_INTERFACES_HOST = '0.0.0.0' + export async function runWeb(argv: string[]): Promise { const { values } = parseArgs({ args: argv, - options: { port: { type: 'string', default: '3080' } }, + options: { + host: { type: 'string', default: LOOPBACK_HOST }, + port: { type: 'string', default: '3080' }, + }, allowPositionals: false, }) + if (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) + } + const hostAddress = values.host const port = Number(values.port) - if (!Number.isInteger(port) || port <= 0 || port > 65535) { + if (!Number.isInteger(port) || port < 0 || port > 65535) { process.stderr.write(`dsh web: invalid --port ${values.port}\n`) process.exit(1) } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ boot: { persistenceRoot: './.sessions' } }) + const host = await startHost({ + boot: { + persistenceRoot: './.sessions', + workspaceContext: { maxBytes: 65_536 }, + }, + }) // Web UI plugin chain: in-memory Loader tree over the eight UI packages, // then the registry that feeds __DSH_BOOT__ and /plugins//client.js. @@ -65,7 +83,7 @@ export async function runWeb(argv: string[]): Promise { let server: Awaited> try { server = await startWebServer( - { port, distIndex, apiHandler: host.handler, webPlugins }, + { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins }, (err: Error) => { process.stderr.write(`dsh web: ${String(err)}\n`) void shutdown(1) @@ -78,11 +96,12 @@ export async function runWeb(argv: string[]): Promise { process.exit(1) } - // The server binds 0.0.0.0 (remote-container + LAN-browser is the primary scenario); - // print the LAN address alongside loopback so the printed URL is copy-usable from outside. - const lan = Object.values(networkInterfaces()).flat() - .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - console.log(`dsh web: http://127.0.0.1:${server.port}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`) + const lan = hostAddress === ALL_INTERFACES_HOST + ? Object.values(networkInterfaces()).flat() + .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + : undefined + const localUrl = `http://${LOOPBACK_HOST}:${server.port}` + console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`) process.on('SIGTERM', () => { void shutdown(0) }) process.on('SIGINT', () => { void shutdown(130) }) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 291e871e63..3fccde9a79 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,10 +1,10 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real -// chromium. First describe: manifest injection + fail-loud half. Second -// describe: the settled success pass — five REAL tsdown bundles (the -// infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation -// round lands in smoke-real under the W5 real-host standard. +// chromium. First describe: manifest injection + static serving. Second +// describe: the settled success pass — seven REAL tsdown bundles (the +// infrastructure four + layout/sidebar/conversation) load through the DI +// chain in ?fixture mode and the three-column frame appears in one flip. The +// full conversation round lands in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -17,13 +17,15 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass (immediately four + layout/sidebar). */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ @@ -44,6 +46,7 @@ describe('web boot chain (keyless, real carrier)', () => { const port = await probeFreePort() const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) } server = await startWebServer({ + host: '127.0.0.1', port, distIndex: DIST_INDEX, apiHandler, @@ -75,22 +78,13 @@ describe('web boot chain (keyless, real carrier)', () => { expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin') }) - it('boots to the loading page and fail-louds the absent plugin', async () => { - onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud')) - await page.waitForSelector('text=HARNESS', { timeout: 10_000 }) - await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 }) - await page.waitForSelector('text=@probe/absent', { timeout: 2000 }) - // The real UI must not have flipped in: the gate opens only on settled(). - expect(await page.locator('[class*="frame"]').count()).toBe(0) - }) - it('applies the token sheets before any plugin CSS', async () => { const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family')) expect(family.trim().length).toBeGreaterThan(0) }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', () => { const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -110,6 +104,7 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( // ?fixture never opens HTTP streams; /api is a tripwire like the first describe. const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) } server = await startWebServer({ + host: '127.0.0.1', port, distIndex: DIST_INDEX, apiHandler, @@ -139,6 +134,66 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( const owners = await page.evaluate(() => [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin'])) expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') + expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar') + }) + + it('collapsed sidebar animates to a 56px rail with the four controls', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail')) + const frame = page.locator('[class*="frame"]') + const firstTrack = async (): Promise => (await frame.evaluate( + el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]! + // The tracks transition on the deepsuite curve; assert the animated + // settle rather than an instant jump. + const settledTrack = async (px: string): Promise => { + await expect.poll(firstTrack, { timeout: 2000 }).toBe(px) + } + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + // Mid-collapse the wide chrome is still mounted, fading — not swapped out. + expect(await page.locator('text=HARNESS').count()).toBe(1) + await settledTrack('56px') + await expect.poll(() => page.locator('text=HARNESS').count(), { timeout: 2000 }).toBe(0) + for (const name of ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) { + await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true) + } + await page.getByRole('button', { name: 'Expand sidebar' }).click() + await settledTrack('300px') + await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) + // Rail search: collapse again, the search control expands and lands in the box. + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + await settledTrack('56px') + await page.getByRole('button', { name: 'Search sessions' }).click() + await settledTrack('300px') + const focused = await page.evaluate(() => + (document.activeElement as HTMLInputElement | null)?.placeholder ?? '') + expect(focused).toContain('Search') + }) + + it('renders file tool rows and expands fixture reasoning from either click target', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure')) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"][aria-selected]').first().click() + + const thinkRoot = page.locator('[data-variant="think"]').first() + const think = thinkRoot.getByRole('button') + await think.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await think.getAttribute('aria-expanded')).toBe('false') + + await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click() + expect(await think.getAttribute('aria-expanded')).toBe('true') + expect(await thinkRoot.locator(':scope > div').count()).toBe(2) + + await think.getByText('Think', { exact: true }).click() + expect(await think.getAttribute('aria-expanded')).toBe('false') + + const editRoot = page.locator('[data-variant="edit"]').first() + await editRoot.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1) + expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1) + + const writeRoot = page.locator('[data-variant="write"]').first() + await writeRoot.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1) + expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 95b293d0e7..9ccdcad606 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -15,7 +15,8 @@ // and theme after, reload recovery last. Tests run sequentially in-file. import type { ChildProcess } from 'node:child_process' import { spawn } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,6 +58,25 @@ function waitForReadyLine(child: ChildProcess): Promise { }) } +async function rpc(baseUrl: string, method: string, payload: unknown): Promise { + const response = await fetch(`${baseUrl}/api/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: `smoke-${method}`, + method, + payload, + }), + }) + if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`) + const body = await response.json() as { + result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } + } + if (!body.result.ok) throw new Error(`${method} failed: ${body.result.error.code}: ${body.result.error.message}`) + return body.result.value +} + /** W5 screenshot: evidence for the figma comparison, not a failure artifact. */ async function screen(page: Page, name: string): Promise { await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) }) @@ -85,6 +105,124 @@ const notReady = UI_PLUGIN_DIRS.filter((dir) => { }) if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`) +describe('dsh web keyless CLI smoke', () => { + it('listens on 127.0.0.1 by default', async () => { + requireDist() + const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-')) + const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + const child = spawn( + process.execPath, + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + { + cwd: sessionsDir, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-no-call', + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + try { + const readyUrl = await waitForReadyLine(child) + expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + expect((await fetch(readyUrl)).status).toBe(200) + } finally { + const closed = child.exitCode === null + ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) + : Promise.resolve() + if (child.exitCode === null) child.kill('SIGTERM') + await closed + rmSync(sessionsDir, { recursive: true, force: true }) + } + }) + + it('injects the invoking workspace AGENTS.md into the provider request', async () => { + requireDist() + const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-')) + mkdirSync(join(workspace, '.git')) + writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n') + + let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void + const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => { + resolveProviderRequest = resolve + }) + const provider = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] }) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end([ + 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', + 'data: {"choices":[{"delta":{"content":"done"}}]}', + 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + }) + }) + await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve)) + const address = provider.address() + if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port') + const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + const child = spawn( + process.execPath, + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + { + cwd: workspace, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-workspace', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DSH_HOME: join(workspace, '.dsh'), + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + try { + const baseUrl = await waitForReadyLine(child) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) + await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + sessionId: created.sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'go' }], + }) + const captured = await Promise.race([ + providerRequest, + new Promise((_resolve, reject) => { + setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref() + }), + ]) + const workspaceMessage = captured.messages?.find(message => + message.role === 'user' && message.content?.includes('web-workspace-context-probe')) + expect(workspaceMessage).toMatchInlineSnapshot(` + { + "content": " + The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. + + Instructions from: AGENTS.md + + web-workspace-context-probe + + ", + "role": "user", + } + `) + } finally { + const closed = child.exitCode === null + ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) + : Promise.resolve() + if (child.exitCode === null) child.kill('SIGTERM') + await closed + await new Promise(resolveClose => provider.close(() => { resolveClose() })) + rmSync(workspace, { recursive: true, force: true }) + } + }) +}) + describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => { let child: ChildProcess let sessionsDir: string diff --git a/assets/community-wecom-survey.png b/assets/community-wecom-survey.png new file mode 100644 index 0000000000..d6f6040ba7 Binary files /dev/null and b/assets/community-wecom-survey.png differ diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 2134c8b355..b732708e1f 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -23,7 +23,7 @@ sequenceDiagram Driver-->>SDK: agent/status running Driver->>Session: turn/start Driver->>Hooks: agent/prompt-submit waterfall - Hooks-->>Driver: allow, block, or add context + Hooks-->>Driver: authoritative allow, block, or add context Driver->>Session: user/message or rejected turn/end Driver->>Prompt: system-prompt/assemble waterfall Driver-->>Driver: agent/pre-step serial checkpoint @@ -51,7 +51,7 @@ sequenceDiagram Driver->>Session: tool/result end end - Driver->>Session: post-tool context and steering + Driver->>Session: post-tool context and steering (no prompt-submit) Driver->>Hooks: agent/post-step serial checkpoint Driver->>Session: step/end Driver->>Hooks: agent/turn-continuation waterfall @@ -66,6 +66,8 @@ The `assistant/message` edge records every successful provider call, including c `dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. +The returned `agent/prompt-submit` allow is authoritative; listeners wrapping `next()` preserve downstream content and additional contexts unless replacement is intentional. Steering bypasses that waterfall and joins at its durable checkpoint. + SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. Maintenance mode: curated Mermaid sequence; exact event signatures live in the generated Cordis catalog. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ca3e7d5bdd..f5079d64fc 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: d7555368b583203cee664da3615c4fb4fb680c30 -architecture.zh.md: bafe59dee0559d5969c7400bdae85be0d012b3b5 +architecture.md: 46b103ec788adbf7673e8b75643c71191318b42f +architecture.zh.md: 2684fe745fe8afd9ebf79f047dd9798ff432e506 diff --git a/docs/architecture.md b/docs/architecture.md index d7555368b5..46b103ec78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,6 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | +| `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | @@ -62,7 +63,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains prompt-to-checkpoint work through plugin-visible services and events. +The shipped loop runs prompt-to-checkpoint work through plugin services and events. A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. @@ -80,17 +81,17 @@ forever: emit agent/status(running) TURN: 'turn/start' - claimed message -> agent/prompt-submit - allowed prompt -> 'user/message' plus injected context + claimed message + contexts -> agent/prompt-submit + allowed prompt -> 'user/message' with prompt-prefix context baked in; append separate contexts blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) STEP loop: - drain steering + drain steering with the same prefix/separate context placement (no prompt-submit) assemble system prompt and tool schemas agent/session-prefix (first step) agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> llm/stream (frozen) + agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) @@ -102,10 +103,10 @@ forever: schedule tool calls by ctx.tools.executionMode: exclusive -> one-call barrier parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute each model-order result -> ordered tools/post-execute -> 'tool/result' append accepted tool-batch context after all recorded results, then steering - agent/post-step + agent/post-step -> checkpoint complete response/results 'step/end' agent/turn-continuation agent/turn-stop (terminal policy) @@ -122,11 +123,11 @@ Pruning precedes summaries; overflow retries require durable progress. Bounded t ### Failure Boundaries -The turn contains failures. Adapter failures close the step before `agent/request-error`, which receives exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool. +Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool. Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. +Session events are turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. ### Agent Handles @@ -144,7 +145,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw **Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends buffer synchronous `session/event` notifications; the loop awaits a turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract. +Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). `ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). @@ -177,13 +178,14 @@ New behavior attaches to a documented extension point; a loop change updates thi | Add a model provider | register an adapter on `ctx.llm` | | Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | | Add shell execution | implement and register a `ctx.bash` backend | +| Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` | | Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | | Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | | Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop | | Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it | -| Add UI or editor integration | drive `ctx.agents` and render from `session/event` | +| Add UI or editor integration | drive `ctx.agents` and render from `session/event`; terminal-only overlays use `ctx.tui` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | | Add asynchronous session-title generation | register the sole provider on `ctx.sessionTitle` | | Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index bafe59dee0..2684fe745f 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,6 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | +| `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | 执行模型编写的程序 | @@ -62,7 +63,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` ## 默认循环生命周期 -已交付的循环通过插件可见的服务和事件,持续处理从提示词到检查点的工作。 +已交付的循环通过插件服务和事件,处理从提示词到检查点的工作。 **会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 @@ -80,17 +81,17 @@ forever: emit agent/status(running) TURN: 'turn/start' - claimed message -> agent/prompt-submit - allowed prompt -> 'user/message' plus injected context + claimed message + contexts -> agent/prompt-submit + allowed prompt -> 'user/message' with prompt-prefix context baked in; append separate contexts blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) STEP loop: - drain steering + drain steering with the same prefix/separate context placement (no prompt-submit) assemble system prompt and tool schemas agent/session-prefix (first step) agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> llm/stream (frozen) + agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) @@ -102,10 +103,10 @@ forever: schedule tool calls by ctx.tools.executionMode: exclusive -> one-call barrier parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute each model-order result -> ordered tools/post-execute -> 'tool/result' append accepted tool-batch context after all recorded results, then steering - agent/post-step + agent/post-step -> checkpoint complete response/results 'step/end' agent/turn-continuation agent/turn-stop (terminal policy) @@ -122,11 +123,11 @@ forever: ### 失败边界 -轮次负责隔离故障。适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启另一个步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交消息或工具。 +适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启另一个步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交消息或工具。 其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -每个会话事件都包围在轮次内。重新加载会保留中断的日志尾部,并用合成的 `interrupted` 轮次结束事件将其闭合。持久轮次关闭后的故障只通过 `agent/error` 报告,因为此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 +会话事件均位于轮次边界内。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 ### Agent 句柄 @@ -144,7 +145,7 @@ forever: **模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会缓冲同步的 `session/event` 通知;循环等待轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约。 +持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 `ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 @@ -177,13 +178,14 @@ forever: | 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | | 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 进入提示词组装流程 | | 添加 shell 执行 | 实现并注册 `ctx.bash` 后端 | +| 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派该命令 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | | 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方,或监听 `fs/*` 策略事件 | | 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成进程前包装 argv | | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stop` 是串行终止判定点 | | 添加历史记录之外的会话稳定前缀 | 组合 `agent/session-prefix`;请求头会记录该前缀 | -| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | +| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染;仅终端可用的浮层使用 `ctx.tui` | | 添加持久会话状态 | 添加一个 `SessionEventMap` 成员,并从日志渲染和回放 | | 添加异步会话标题生成 | 在 `ctx.sessionTitle` 上注册唯一提供方 | | 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent` 和 `agent/*` 续跑 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2832cec245..544d1bdd61 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -36,6 +36,9 @@ flowchart LR pkg_hooks_codex["hooks-codex"] pkg_acp["acp"] svc_sessionQuery["ctx.sessionQuery
Exact session-history reads and traces"] + pkg_session_reference["session-reference"] + svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] + pkg_tui["tui"] pkg_session_title["session-title"] svc_sessionTitle["ctx.sessionTitle
Log-backed session titles"] pkg_session_title_first_message_llm["session-title-first-message-llm"] @@ -44,6 +47,7 @@ flowchart LR svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] pkg_tools["tools"] pkg_tool_fs["tool-fs"] + pkg_tool_pty["tool-pty"] pkg_tool_web["tool-web"] svc_tools["ctx.tools
Tool registry and guarded execution pipeline"] pkg_tool_ask_user["tool-ask-user"] @@ -53,11 +57,11 @@ flowchart LR pkg_tool_todo["tool-todo"] pkg_user_interaction["user-interaction"] svc_userInteraction["ctx.userInteraction
Human question/answer seam"] - pkg_tui["tui"] pkg_plan_mode["plan-mode"] svc_planMode["ctx.planMode
Plan collaboration state"] pkg_commands["commands"] svc_commands["ctx.commands
Human command registry"] + svc_tui["ctx.tui
Mounted-terminal interaction service"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] @@ -72,6 +76,9 @@ flowchart LR pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] + pkg_pty["pty"] + svc_pty["ctx.pty
Persistent PTY session registry"] + pkg_pty_local["pty-local"] pkg_sandbox["sandbox"] svc_sandbox["ctx.sandbox
Process-sandbox seam"] pkg_sandbox_local["sandbox-local"] @@ -139,6 +146,8 @@ flowchart LR pkg_llm_replay --> svc_llm pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode + pkg_pty --> svc_pty + pkg_pty_local --> svc_pty pkg_sandbox --> svc_sandbox pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy @@ -147,6 +156,7 @@ flowchart LR pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_query --> svc_sessionQuery + pkg_session_reference --> svc_sessionReferences pkg_session_title --> svc_sessionTitle pkg_session_title_all_messages_llm --> svc_sessionTitle pkg_session_title_first_message_llm --> svc_sessionTitle @@ -163,6 +173,7 @@ flowchart LR pkg_token_meter --> svc_tokenMeter pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools + pkg_tui --> svc_tui pkg_tui --> svc_userInteraction pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -196,15 +207,21 @@ flowchart LR svc_llm --> pkg_compact_basic svc_permission --> pkg_acp svc_planMode --> pkg_acp + svc_pty --> pkg_tool_pty svc_sandbox --> pkg_bash_sandbox + svc_sandbox --> pkg_pty_local svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox + svc_sandboxPolicy --> pkg_pty_local svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_codex svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_tool_bash + svc_sessionQuery --> pkg_session_reference + svc_sessionReferences --> pkg_acp + svc_sessionReferences --> pkg_tui svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_cli_demo @@ -217,9 +234,11 @@ flowchart LR svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs + svc_systemPrompt --> pkg_tool_pty svc_systemPrompt --> pkg_tool_web svc_systemPrompt --> pkg_tools svc_tasks --> pkg_tool_bash + svc_tasks --> pkg_tool_pty svc_tasks --> pkg_tool_subagent svc_tasks --> pkg_tool_tasks svc_tokenMeter --> pkg_compact_basic @@ -230,6 +249,7 @@ flowchart LR svc_tools --> pkg_tool_bash svc_tools --> pkg_tool_cordis svc_tools --> pkg_tool_fs + svc_tools --> pkg_tool_pty svc_tools --> pkg_tool_skill svc_tools --> pkg_tool_subagent svc_tools --> pkg_tool_todo @@ -251,28 +271,31 @@ 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), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | 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), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | [`session-reference`](../packages/context/session-reference) | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | +| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | 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-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | +| `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. | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | [`acp`](../packages/ui/acp) | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model. | +| `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | -| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | -| `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | +| `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | +| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox), [`pty-local`](../packages/pty/pty-local) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | +| `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`pty-local`](../packages/pty/pty-local) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | | `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. | -| `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | +| `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f202990b82..d2b9e68ae1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` ## `@deepseek-ai/dsh-acp` -Requires: `agents` · `commands` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` +Requires: `agents` · `commands` · `sessionPersistence` · `sessionQuery` · `tools` · `userInteraction` · `llm` · `systemPrompt` ```ts config-catalog /** Plugin config: the agent template ACP sessions are created from. */ @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:275`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:285`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -60,8 +60,12 @@ export interface Config { sessionTitle?: NonNullable /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string + /** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */ + packChunks?: boolean /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression + /** Cross-session reference discovery and snapshot byte budgets. */ + sessionReferences?: SessionReferenceConfig /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ @@ -77,9 +81,9 @@ 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) +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:38`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:43`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -218,11 +222,10 @@ Requires: `sandbox` · `sandboxPolicy` ```ts config-catalog /** * Plugin config: the local executor's knobs, verbatim. The sandbox policy — - * the default mode and the `workspace-write` boundary root — is NOT here: it - * lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one - * home both enforcing families read, so bash and fs can never confine to - * different roots. The runner choice is likewise the `ctx.sandbox` provider's - * config, not this executor's. + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for both enforcing families. The runner + * choice is likewise the `ctx.sandbox` provider's config, not this executor's. */ export type Config = LocalConfig ``` @@ -271,7 +274,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/cli-demo/src/index.ts:25`](../packages/examples/cli-demo/src/index.ts) +Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -389,8 +392,8 @@ Requires: `sandboxPolicy` /** * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve * base for relative paths). The sandbox default (mode + `workspace-write` - * boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home - * both enforcing families share. + * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling + * session for both enforcing families. */ export type Config = LocalConfig ``` @@ -639,7 +642,7 @@ export interface ReplayModelConfig { } ``` -Source: [`packages/support/llm-replay/src/index.ts:385`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:387`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -800,6 +803,44 @@ export interface PlanModeConfig { Source: [`packages/plan/plan-mode/src/index.ts:57`](../packages/plan/plan-mode/src/index.ts) +## `@deepseek-ai/dsh-pty-local` + +Requires: `pty` · `sandbox` · `sandboxPolicy` + +```ts config-catalog +/** Public plugin configuration. */ +export interface Config { + /** Backend registry type (default: `shell`). */ + backendType?: string + /** Interactive shell executable (default: `/bin/bash`). */ + shellPath?: string + /** Shell arguments (default: `--noprofile --norc -i`). */ + shellArgs?: string[] + /** Terminal rows. */ + rows?: number + /** Terminal columns. */ + cols?: number + /** Maximum retained logical lines. */ + scrollbackLines?: number + /** Maximum retained UTF-8 bytes. */ + scrollbackMaxBytes?: number + /** Maximum bytes returned by one read or settled viewport. */ + maxReadBytes?: number + /** Readiness polling interval. */ + pollIntervalMs?: number + /** Delay before Linux exact syscall probes. */ + exactProbeAfterMs?: number + /** Silence duration that yields `inferred_idle`. */ + idleSilenceMs?: number + /** Absolute send wait bound. */ + timeoutMs?: number + /** Grace before teardown escalates to `SIGKILL`. */ + disposeGraceMs?: number +} +``` + +Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -874,8 +915,8 @@ export interface Config { /** File-sandbox mode a session starts from (default: `read-only`). */ mode?: SandboxMode /** - * Absolute root directory `workspace-write` may write under (default: - * `process.cwd()`). Both enforcing families fence against this SAME root. + * Fallback root for agentless calls and sessions without a cwd (default: + * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string } @@ -890,7 +931,7 @@ Source: [`packages/sandbox/sandbox-policy/src/index.ts:44`](../packages/sandbox/ Requires: `sessions` ```ts config-catalog -/** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */ +/** Plugin config: where the JSONL backend keeps its session logs, and the packed-row write switch. */ export interface Config { /** * Root directory for all session files. Required (no default): a default of @@ -898,6 +939,15 @@ export interface Config { * (bash calls, subprocesses). Sessions group under per-cwd subdirectories. */ root: string + /** + * Write runs of consecutive `assistant/chunk` delta events as packed + * `text-chunks`/`reasoning-chunks`/`tool-call-chunks` rows (lossless, + * ~60% smaller logs measured on a real session). Off by default while + * snapshot fixtures stay in the one-event-per-line layout: recording with + * packing on rewrites every golden `session.jsonl`. READING packed rows is + * unconditional — a log's layout never depends on this switch. + */ + packChunks?: boolean /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression } @@ -961,6 +1011,24 @@ export interface Config { Source: [`packages/session-query/session-query/src/config.ts:9`](../packages/session-query/session-query/src/config.ts) +## `@deepseek-ai/dsh-session-reference` + +Requires: `sessionQuery` + +```ts config-catalog +/** Session-reference service configuration. */ +export interface Config { + /** Maximum distinct source sessions referenced by one message, from one to three. */ + maxReferences?: number + /** Default host candidate-list limit. */ + candidateLimit?: number + /** Maximum rendered UTF-8 bytes for one source snapshot. */ + maxReferenceBytes?: number +} +``` + +Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) + ## `@deepseek-ai/dsh-session-title` Requires: `sessions` @@ -977,7 +1045,7 @@ export interface Config { } ``` -Source: [`packages/session-title/session-title/src/index.ts:69`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:70`](../packages/session-title/session-title/src/index.ts) ## `@deepseek-ai/dsh-session-title-all-messages-llm` @@ -1522,7 +1590,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:145`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:216`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` @@ -1549,6 +1617,8 @@ export interface Config { persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression + /** Cross-session reference discovery and snapshot byte budgets. */ + sessionReferences?: SessionReferenceConfig /** TUI transcript's optional first line; absent renders nothing on start. */ welcome?: string /** @@ -1575,9 +1645,9 @@ 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) · [`uiTui`](../packages/ui/tui/src/index.ts) +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:32`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:38`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1796,11 +1866,14 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) +- `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) +- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) +- `@deepseek-ai/dsh-tool-pty` — requires `pty` · `tools` · `systemPrompt` ([`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/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)) diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 6fd3feebbd..234db6273e 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.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 -adding-a-package.md: 556a48493af4452c178634c0abb4e23e2419dd8e -adding-a-package.zh.md: 5f7e4692233448c746d25e4808c078c390cf39e6 +adding-a-package.md: 1859310965538b35a353ee05c94b01d1093a3e43 +adding-a-package.zh.md: 22f574a0469609e44f5c55957560ff0f04b9a053 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 556a48493a..1859310965 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -32,10 +32,11 @@ In-package relative imports use explicit `.ts` specifiers in source (for example | File | Change | |---|---| | `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages//*/src` candidate to the `@deepseek-ai/dsh-*` wildcard | -| `tsconfig.json` | add `{ "path": "./packages//" }` to `references` | -| `tsconfig.build.json` | add `{ "path": "./packages//" }` to `references` | +| `tsconfig.host.json` (host-side package) or `tsconfig.client.json` (client-side package) | add `{ "path": "./packages//" }` to `references` — exactly one aggregate, never both ([layout](../development.md#typescript-project-layout)) | | `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) | +A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. + Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`, `scripts/check-workspace-constraints.ts`. ## 3. Decide the package topology diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 5f7e469223..22f574a046 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -32,10 +32,11 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c | 文件 | 变更 | |---|---| | `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages//*/src` 候选路径 | -| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./packages//" }` | -| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./packages//" }` | +| `tsconfig.host.json`(host 侧包)或 `tsconfig.client.json`(client 侧包) | 在 `references` 中添加 `{ "path": "./packages//" }`——恰好一个聚合,绝不两个都加([布局](../development.md#typescript-project-layout)) | | `knip.json` | 仅当包有非 `*.spec.ts` 入口时需要(如 `*.e2e.ts` → 添加 per-workspace override,参照 `packages/llm/llm-deepseek`) | +`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 + 以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`、`scripts/check-workspace-constraints.ts`。 ## 3. 确定包拓扑 diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml index b3ca7ca791..98c5ee7696 100644 --- a/docs/cookbook/adding-a-vendored-package.i18n.yaml +++ b/docs/cookbook/adding-a-vendored-package.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 -adding-a-vendored-package.md: 1b82f2e582ca5cd040a7f3237505848dbb304fae -adding-a-vendored-package.zh.md: 7245682ef8b7d85ace2c626f8d47aa36f739506b +adding-a-vendored-package.md: 71ca9fccc9418348784dbb6668127242e4fb45d2 +adding-a-vendored-package.zh.md: c340630aebeda0ec293a835cdfc8d15d71cd7801 diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index 1b82f2e582..71ca9fccc9 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -38,8 +38,7 @@ Local relative imports/exports in vendored TypeScript source use explicit `.ts` | File | Change | |---|---| | `tsconfig.base.json` | add `"": ["./vendor//src"]` to `paths` | -| `tsconfig.json` | add `{ "path": "./vendor/" }` to `references` | -| `tsconfig.build.json` | add `{ "path": "./vendor/" }` to `references` (before the `packages/*` entries) | +| `tsconfig.host.json` | add `{ "path": "./vendor/" }` to `references` (before the `packages/*` entries; vendored code enters the graph through the host aggregate only) | | `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications | | `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) | @@ -57,4 +56,4 @@ pnpm run typecheck pnpm run build && pnpm run test && pnpm run constraints ``` -The source `paths` map is shared by build and root typecheck configs. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor//tsconfig.json`, not pulled into a root strict program. +The source `paths` map lives once in `tsconfig.base.json` and serves every graph. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor//tsconfig.json`, not pulled into an aggregate's strict program ([layout](../development.md#typescript-project-layout)). diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md index 7245682ef8..c340630aeb 100644 --- a/docs/cookbook/adding-a-vendored-package.zh.md +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -38,8 +38,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显 | 文件 | 修改内容 | |---|---| | `tsconfig.base.json` | 在 `paths` 中添加 `"": ["./vendor//src"]` | -| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./vendor/" }` | -| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./vendor/" }`(置于 `packages/*` 条目之前) | +| `tsconfig.host.json` | 在 `references` 中添加 `{ "path": "./vendor/" }`(置于 `packages/*` 条目之前;vendored 代码只经 host 聚合进图) | | `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 | | `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) | @@ -57,4 +56,4 @@ pnpm run typecheck pnpm run build && pnpm run test && pnpm run constraints ``` -源码 `paths` 映射由构建配置和根类型检查配置共享。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor//tsconfig.json` 被引用,而非被拉入根目录的严格程序中。 +源码 `paths` 映射只在 `tsconfig.base.json` 存在一份,服务所有图。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor//tsconfig.json` 被引用,而非被拉入某个聚合的严格程序中([布局](../development.md#typescript-project-layout))。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c5ce69f64f..4ca3fd0601 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:365`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -119,7 +119,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:296`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -142,18 +142,21 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall -Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. The signal controls only this turn; listeners may cooperate with it but must not retain it to control another turn. +Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. A listener wrapping a downstream `allow` must preserve its `content` and `additionalContexts` unless it intentionally replaces them. The signal controls only this turn; listeners may cooperate with it but must not retain it to control another turn. Steering messages do not dispatch this event; they join an open turn at a steering checkpoint. ```ts cordis-catalog /** * Allow, rewrite, or block one claimed prompt before it becomes a user - * message. Call `next()` for the unchanged default. The signal controls only - * this turn; listeners may cooperate with it but must not retain it to - * control another turn. + * message. Call `next()` for the unchanged default. A listener wrapping a + * downstream `allow` must preserve its `content` and `additionalContexts` + * unless it intentionally replaces them. The signal controls only this turn; + * listeners may cooperate with it but must not retain it to control another + * turn. Steering messages do not dispatch this event; they join an open turn + * at a steering checkpoint. * @param agent - the agent whose turn claimed the message. * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. @@ -166,7 +169,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -178,16 +181,16 @@ Detached, frozen content entered the agent's inbox. Source defaults have already * already been applied, so these are the exact values retained for the log. * @param agent - the agent whose inbox received the message. * @param content - the accepted content blocks retained by the inbox. - * @param info - the accepted source plus whether it entered as steering. + * @param info - the accepted source, contexts, and whether it entered as steering. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void +'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void ``` -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [HookContext](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -212,7 +215,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:257`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -238,7 +241,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -264,7 +267,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -286,7 +289,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -306,7 +309,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -329,7 +332,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -351,7 +354,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:322`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:341`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -373,7 +376,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -569,7 +572,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:68`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -590,7 +593,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:89`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -613,7 +616,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:90`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -634,7 +637,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:100`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e73fce4169..5ac93a39de 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -383,7 +383,7 @@ Source: [`packages/ui/commands/src/index.ts:227`](../../packages/ui/commands/src ## `ctx.compact` — `CompactService` (abstract seam) -Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. +Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses COMPACT_CHECKPOINT_SOURCE so consumers recognize it independently of the backend. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog /** @@ -407,6 +407,7 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger * balanced so assistant tool calls remain paired with their results. A model- * backed implementation forwards cancellation and rejects active, missing, * reversed, or unbalanced ranges. The target session is `agent.session`. + * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * @@ -422,7 +423,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md) -Source: [`packages/compact/compact/src/index.ts:39`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) @@ -499,12 +500,12 @@ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this write runs under; a - * sandboxing backend fences the write by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this write + * runs under; a sandboxing backend fences the write by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the write produced. */ -abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise +abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise /** * Atomically edit literal text. When supplied, the version guard is checked @@ -514,15 +515,15 @@ abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this edit runs under; a - * sandboxing backend fences the edit by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this edit runs + * under; a sandboxing backend fences the edit by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the edit produced. */ -abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise +abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise ``` -Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsPathInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) · [SandboxMode](../core-data-structures/sandbox.md) +Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsPathInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) · [SandboxExecutionPolicy](../core-data-structures/sandbox.md) Source: [`packages/fs/fs/src/index.ts:81`](../../packages/fs/fs/src/index.ts) @@ -760,6 +761,81 @@ Types: [Agent](../core-data-structures/core.md) Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts) +## `ctx.pty` — `PtyService` + +In-process registry for replaceable PTY backends and exact-Agent sessions. + +```ts cordis-catalog +/** + * Register one backend type for this effect scope. + * @param backend - provider with a non-empty unique type. + * @returns disposer that removes exactly this contribution. + */ +registerBackend(backend: PtyBackend): () => void + +/** + * List registered backend types in registration order. + * @returns fresh backend type names. + */ +listBackends(): string[] + +/** + * Create and publish one owner-scoped session after backend setup succeeds. + * @param owner - exact registered Agent that owns access and cleanup. + * @param request - backend type plus optional owner-local name and cwd. + * @param signal - cancellation of unpublished setup. + * @returns published identity, metadata, status, and MOTD. + */ +async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise + +/** + * Start one exclusive interactive send. + * @param owner - exact session owner. + * @param id - target PTY identity. + * @param request - explicit text, submit behavior, and cancellation. + * @returns live operation handle for foreground await or task registration. + */ +startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation + +/** + * Read one bounded scrollback page from an owned session. + * @param owner - exact session owner. + * @param id - target PTY identity. + * @param request - optional newest-relative offset and line count. + * @returns bounded retained text and pagination metadata. + */ +read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult + +/** + * Deliver an allowed signal through an owned backend session. + * @param owner - exact session owner. + * @param id - target PTY identity. + * @param signal - allowed POSIX signal name. + * @returns delivered foreground process-group identity. + */ +signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise + +/** + * Close one owned session and remove it only after quiescent backend cleanup. + * @param owner - exact session owner. + * @param id - target PTY identity. + * @param reason - diagnostic cleanup reason. + * @returns true for a newly closed session, false when the same close is already in flight. + */ +async kill(owner: Agent, id: PtySessionId, reason = 'model request'): Promise + +/** + * List fresh snapshots for exactly one owner. + * @param owner - exact owner whose sessions are visible. + * @returns owner-visible snapshots in publication order. + */ +list(owner: Agent): PtySessionSnapshot[] +``` + +Types: [Agent](../core-data-structures/core.md) · [PtyBackend](../core-data-structures/pty.md) · [PtyReadRequest](../core-data-structures/pty.md) · [PtyReadResult](../core-data-structures/pty.md) · [PtySendOperation](../core-data-structures/pty.md) · [PtySendRequest](../core-data-structures/pty.md) · [PtySessionId](../core-data-structures/pty.md) · [PtySessionSnapshot](../core-data-structures/pty.md) · [PtySignal](../core-data-structures/pty.md) · [PtySignalResult](../core-data-structures/pty.md) · [PtySpawnRequest](../core-data-structures/pty.md) · [PtySpawnResult](../core-data-structures/pty.md) + +Source: [`packages/pty/pty/src/index.ts:95`](../../packages/pty/pty/src/index.ts) + ## `ctx.sandbox` — `SandboxProvider` (abstract seam) Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end. @@ -781,13 +857,28 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:122`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode and workspace root; enforcing implementations read defaultMode and workspaceRoot, and the tool layers fold each session's `sandbox/mode` override with effectiveSandboxMode on top. +The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode and fallback workspace root. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. -Source: [`packages/sandbox/sandbox-policy/src/index.ts:60`](../../packages/sandbox/sandbox-policy/src/index.ts) +```ts cordis-catalog +/** + * Resolve the complete policy for one capability call. An approved explicit + * mode outranks the session's last `sandbox/mode` event, which outranks the + * deployment default. A session cwd is its workspace-write boundary; the + * configured root is the fallback for agentless calls and sessions without a + * cwd. + * @param request - optional session and approved mode override. + * @returns the fully resolved per-call mode and absolute workspace root. + */ +resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy +``` + +Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) + +Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) @@ -869,6 +960,14 @@ async readTitle(sessionId: SessionId): Promise */ async listEvents(sessionId: SessionId): Promise +/** + * Read one session's complete current model surface from one corpus observation. + * @param sessionId - live-preferred session id to read. + * @returns cloned header, current surface, and raw-log capture boundary. + * @throws when source resolution fails or the session surface is invalid. + */ +async readSurface(sessionId: SessionId): Promise + /** * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. @@ -893,9 +992,39 @@ async traceEvent(request: SessionEventTraceRequest): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../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) · [SessionRecord](../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) · [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) · [SessionRecord](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:40`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:41`](../../packages/session-query/session-query/src/index.ts) + +## `ctx.sessionReferences` — `SessionReferenceService` + +Exact-read consumer that prepares immutable cross-session message context. + +```ts cordis-catalog +/** + * List reference candidates, ranked by working-directory affinity. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd substring. + * @param limit - optional positive result cap. + * @param signal - optional cancellation boundary for host autocomplete teardown. + * @returns candidates labeled by latest title or, when absent, session id. + */ +async listCandidates( agent: Agent, query = '', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise + +/** + * Snapshot all references before enqueue and return one aggregated durable context. + * @param agent - target agent; references to it are rejected. + * @param content - already host-normalized readable message content. + * @param references - structured source sessions in mention order. + * @param signal - optional cancellation boundary for host request teardown. + * @returns detached content and zero or one prepared contexts. + */ +async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [PreparedReferencedMessage](../core-data-structures/session-reference.md) · [SessionReferenceCandidate](../core-data-structures/session-reference.md) · [SessionReferenceInput](../core-data-structures/session-reference.md) + +Source: [`packages/context/session-reference/src/index.ts:69`](../../packages/context/session-reference/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -1044,7 +1173,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:592`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1078,7 +1207,7 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:282`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:284`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` @@ -1485,6 +1614,29 @@ Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core- Source: [`packages/core/tools/src/index.ts:524`](../../packages/core/tools/src/index.ts) +## `ctx.tui` — `TuiExtensionService` (abstract seam) + +Optional terminal-local interaction service provided by one mounted TUI. + +The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugins receive only effect-owned overlay sessions. + +```ts cordis-catalog +/** + * Queue an interactive overlay owned by the calling plugin fiber. + * + * The TUI displays one overlay at a time in FIFO order. Disposing the caller + * removes a queued overlay or closes an active one before plugin teardown + * settles. This live presentation is neither logged nor replayed. + * + * @param request - component factory, layout constraints, and cancellation. + * @returns the effect-owned overlay session. + * @throws when the TUI has begun shutting down. + */ +abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession +``` + +Source: [`packages/ui/tui/src/index.ts:131`](../../packages/ui/tui/src/index.ts) + ## `ctx.userInteraction` — `UserInteractionService` `ctx.userInteraction`: one active UI provider plus an `ask()` surface. diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index d8e92dcb67..64230564b7 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -1,6 +1,6 @@ # Cordis Primer -Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md). +Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; the [Cordis tutorial](cordis-tutorial/index.md) walks the same ideas hands-on. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md). ## Cordis In Five Ideas diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md new file mode 100644 index 0000000000..084f9964e1 --- /dev/null +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -0,0 +1,93 @@ +# 1. Your first plugin + +In the loader configuration used here, a Cordis plugin module named-exports an `apply` function. When Cordis loads it, it calls `apply` with a **context** — the `ctx` object through which the plugin registers everything it contributes. + +## Write the plugin + +In your `tmp/cordis-tutorial` directory (see [setup](index.md#setup)), create `hello.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'hello' + +export function apply(ctx: Context) { + console.log('hello from my first plugin') +} +``` + +The `name` export is optional display metadata; it labels the plugin in diagnostics. + +## Compose the app + +This tutorial's launcher assembles the application from configuration. Create `cordis.yml`: + +```yaml +- name: './hello.ts' +``` + +The file is a list of plugin entries. `name` is a module specifier — a relative path or an npm package name — and the loader mounts every entry. Entries start concurrently, so list position guarantees nothing about which plugin loads first; ordering comes from service dependencies (`inject`, [chapter 3](03-services.md)), not from position in the file. + +## Run it + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +Expected output: + +``` +hello from my first plugin +``` + +The process exits on its own once nothing is left running. What happened: + +1. The launcher created a root `Context` and mounted the **Loader** plugin. +2. The Loader read `cordis.yml`, resolved `./hello.ts`, and mounted it as a child plugin. +3. Cordis called your `apply(ctx)`. + +There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [TUI agent](../../examples/tui-agent/cordis.yml), for example, is a longer plugin composition. + +## The two other plugin shapes + +A function is the most common shape, but Cordis accepts three: + +```ts +import { Service, type Context } from 'cordis' + +// 1. Function plugin (what you just wrote). +export function apply(ctx: Context) {} + +// 2. Object plugin: an object with an `apply` method. +export const objectPlugin = { + name: 'object-plugin', + apply(ctx: Context) {}, +} + +// 3. Class plugin: a Service subclass (covered in chapter 3). +export class MyService extends Service { + constructor(ctx: Context) { + super(ctx, 'myTutorialService') + } +} +``` + +Use the function form until you need to expose a service; [chapter 3](03-services.md) covers when the class form earns its place. + +## Try breaking it + +Make `apply` throw: + +```ts ignore-check +export function apply(ctx: Context) { + throw new Error('apply exploded') +} +``` + +Run again: the process dies with your error. A plugin that fails to load is a loud failure, not a skipped entry. + +One caveat worth knowing early: a config entry whose module cannot be **resolved** — a typo'd path or package name — is reported through the Cordis logger service instead of crashing the process, and at boot that report can be lost before a console exporter is watching. If a freshly added entry seems to do nothing, check the spelling first. + +Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md new file mode 100644 index 0000000000..68a0f12ec8 --- /dev/null +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -0,0 +1,96 @@ +# 2. Lifecycle and effects + +A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in `ctx.effect()`. + +## Effects + +For a resource Cordis does not already manage — a timer, a connection, a watcher — wrap it in `ctx.effect()` and return a disposer: + +Create `lifecycle.ts` in `tmp/cordis-tutorial`: + +```ts +import type { Context } from 'cordis' + +export const name = 'lifecycle-demo' + +function heartbeat(ctx: Context) { + console.log('heartbeat plugin loading') + ctx.effect(() => { + const timer = setInterval(() => console.log('tick'), 200) + return () => { + clearInterval(timer) + console.log('heartbeat cleaned up') + } + }) +} + +export function apply(ctx: Context) { + // Mount a child plugin and keep its fiber to dispose it later. + const fiber = ctx.plugin(heartbeat) + // The demo timer is itself an effect: if THIS plugin is unloaded first, + // the pending callback is cancelled instead of firing on a dead app. + ctx.effect(() => { + const timer = setTimeout(async () => { + await fiber.dispose() + console.log('disposed') + process.exit(0) + }, 700) + return () => clearTimeout(timer) + }) +} +``` + +Point `cordis.yml` at it: + +```yaml +- name: './lifecycle.ts' +``` + +Run (`node --import tsx ../../vendor/cordis/bin.js`) and you get: + +``` +heartbeat plugin loading +tick +tick +tick +heartbeat cleaned up +disposed +``` + +Three things to notice: + +- `ctx.plugin(heartbeat)` mounts a function **from code** as a plugin — the same operation the YAML loader performs for each config entry. A function plugin needs no `apply` method: Cordis calls the function directly and uses its name only for diagnostics. An `apply` method is required only for the object form, `ctx.plugin({ apply(ctx) { /* ... */ } })`. The call returns a **fiber**, the runtime handle for one loaded plugin instance. +- The effect body runs during load; the disposer it returns runs during unload. You never call the disposer yourself for a plugin-lifetime resource. +- `fiber.dispose()` resolves after all of the plugin's cleanup — including async disposers — has finished, and recursively unloads any child plugins it mounted. + +## The fiber state machine + +Every loaded plugin instance owns a fiber that moves through these states: + +``` +PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED + ↘ FAILED +``` + +- **PENDING** — declared, but a required service (chapter 3) is not available yet. +- **LOADING / ACTIVE** — `apply` is running / has completed. +- **FAILED** — `apply` or config validation threw. +- **UNLOADING / DISPOSED** — disposers are running / everything is torn down. + +You will meet PENDING again in [chapter 6](06-composition-and-hmr.md), where it is the usual answer to "why does my plugin print nothing?". + +## What is already an effect + +You rarely write `ctx.effect()` yourself, because the built-in registration APIs are effects already: + +- `ctx.on(event, listener)` — the listener is removed on unload ([chapter 4](04-events.md)). +- `ctx.plugin(child)` — the child is disposed with its parent. +- Service registrations are effects. Harness registries such as `ctx.tools.register(...)` also attach their returned disposers to the calling plugin, so they unwind automatically ([chapter 7](07-into-the-harness.md)). + +For a resource Cordis does not manage, acquire it inside `ctx.effect()` and return a disposer that releases it. Cordis then invokes that release during unloading, including hot reload. + +One ordering caveat: disposers start in reverse registration order, but multiple **async** disposers run concurrently. If teardown steps must run in sequence, keep them in one disposer and await them there. + +Next: [Services](03-services.md) — how plugins share capabilities. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md new file mode 100644 index 0000000000..9f62003e99 --- /dev/null +++ b/docs/cordis-tutorial/03-services.md @@ -0,0 +1,96 @@ +# 3. Services + +A **service** is a named capability one plugin provides and other plugins consume through `ctx`. In the harness, `ctx.tools`, `ctx.llm`, and `ctx.agents` are services. A consumer names the capability, such as `'tools'`, rather than importing its provider, so configuration can select a provider without changing the consumer. + +## Provide a service + +Create `greeter.ts` in `tmp/cordis-tutorial`: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + greeter: GreeterService + } +} + +export class GreeterService extends Service { + constructor(ctx: Context) { + super(ctx, 'greeter') + } + + greet(who: string) { + return `Hello, ${who}!` + } +} + +export const name = 'greeter' + +export function apply(ctx: Context) { + ctx.plugin(GreeterService) +} +``` + +Two pieces work together: + +- **Runtime**: `super(ctx, 'greeter')` registers the instance under the name `greeter`. From then on, any plugin can reach it as `ctx.greeter`. The registration is an effect — unloading the provider removes the service. +- **Compile time**: the `declare module 'cordis'` block is TypeScript declaration merging. It adds `greeter` to the `Context` interface so `ctx.greeter` typechecks everywhere. It generates no code; without it the service still works at runtime, but consumers lose type safety. + +A `Service` subclass is itself a plugin (the class form from chapter 1), so `ctx.plugin(GreeterService)` mounts it like any other. + +## Consume a service with `inject` + +Create `consumer.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'consumer' +export const inject = ['greeter'] + +export function apply(ctx: Context) { + console.log(ctx.greeter.greet('world')) +} +``` + +`inject` lists the services this plugin requires. Cordis holds the plugin in PENDING until every listed service exists, so inside `apply`, `ctx.greeter` is guaranteed ready. Load order in `cordis.yml` does not matter — dependencies, not file order, decide when plugins start. + +Compose and run: + +```yaml +- name: './greeter.ts' +- name: './consumer.ts' +``` + +``` +Hello, world! +``` + +Swap the two lines in `cordis.yml` and rerun: same output. Try removing `./greeter.ts` entirely: the consumer stays PENDING and prints nothing — no crash, no partial run. A PENDING fiber does not keep Node's event loop alive either, so a composition with nothing else running exits 0 silently. [Chapter 6](06-composition-and-hmr.md) shows how to diagnose that state. + +## Dependencies are tracked after load + +`inject` is not a one-shot boot check. If a required service disappears while the app runs — its provider was unloaded or hot-replaced — every dependent plugin is unloaded too, and loads again when the service returns. Combined with effects ([chapter 2](02-lifecycle-and-effects.md)), this prevents a running consumer from retaining a reference to an unavailable service: its own registrations are unwound when the dependency disappears. + +This is also why service replacement works in config: unload the `dsh-bash-local` entry, mount a different `bash` provider, and every plugin injecting `'bash'` cleanly restarts against the new implementation. + +## Optional dependencies + +`inject` is for hard requirements. For a capability the plugin can live without, skip `inject` and probe at the use site: + +```ts ignore-check +export function apply(ctx: Context) { + // undefined when no provider is loaded; the plugin still runs. + const greeter = ctx.get('greeter') + console.log(greeter?.greet('maybe') ?? 'no greeter available') +} +``` + +## Naming + +Service names live in one flat namespace per application. Prefix or namespace your own services distinctively (the harness claims plain names like `tools` and `llm`); the generated [services catalog](../cordis-catalog/services.md) lists every name the harness registers. + +Next: [Events](04-events.md) — communication without a shared service. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md new file mode 100644 index 0000000000..4c14ba5313 --- /dev/null +++ b/docs/cordis-tutorial/04-events.md @@ -0,0 +1,142 @@ +# 4. Events + +Services support direct calls; **events** let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions. + +## Declare, emit, listen + +Create `stats.ts` in `tmp/cordis-tutorial` — a service that counts things and announces each change: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + stats: StatsService + } + interface Events { + 'stats/report'(name: string, count: number): void + } +} + +export class StatsService extends Service { + private counts = new Map() + + constructor(ctx: Context) { + super(ctx, 'stats') + } + + bump(name: string) { + const next = (this.counts.get(name) ?? 0) + 1 + this.counts.set(name, next) + this.ctx.emit('stats/report', name, next) + } +} + +export const name = 'stats' + +export function apply(ctx: Context) { + ctx.plugin(StatsService) +} +``` + +The `interface Events` merge is the event-system twin of the `interface Context` merge from chapter 3: it declares the event name and its listener signature, so `ctx.emit` and `ctx.on` are fully typed. The `namespace/action` naming convention keeps the flat event namespace readable. + +Create `reporter.ts`: + +```ts ignore-check +import type { Context } from 'cordis' +import type {} from './stats.ts' + +export const name = 'reporter' +export const inject = ['stats'] + +export function apply(ctx: Context) { + ctx.on('stats/report', (name, count) => { + console.log(`[stats] ${name} -> ${count}`) + }) + ctx.stats.bump('tool_call') + ctx.stats.bump('tool_call') + ctx.stats.bump('prompt') +} +``` + +The `import type {} from './stats.ts'` line imports nothing at runtime; it exists so TypeScript sees the declaration merges. Compose and run: + +```yaml +- name: './stats.ts' +- name: './reporter.ts' +``` + +``` +[stats] tool_call -> 1 +[stats] tool_call -> 2 +[stats] prompt -> 1 +``` + +Because `ctx.on()` is an effect, the listener disappears with the plugin — no manual `removeListener` bookkeeping, ever. + +## Dispatch modes + +`emit` is one of five dispatch modes. Which one an event uses is part of its contract — it decides whether listeners can return values, run concurrently, or short-circuit each other: + +| Mode | Call | Semantics | +|---|---|---| +| emit | `ctx.emit(name, ...args)` | Synchronous broadcast; returned promises and values are not awaited or collected. | +| parallel | `await ctx.parallel(name, ...args)` | All listeners run concurrently; awaited together. | +| serial | `await ctx.serial(name, ...args)` | Listeners run in order, awaited; the first non-`null`/`false`/`undefined` return wins and stops the rest. | +| bail | `ctx.bail(name, ...args)` | Synchronous version of serial. | +| waterfall | `ctx.waterfall(name, ...args, next)` | Around-middleware; see below. | + +Every harness event documents its mode in the generated [events catalog](../cordis-catalog/events.md). + +## Waterfall: transform or short-circuit + +Waterfall is the mode that powers interception. Each listener receives the arguments plus a `next()` continuation; it can transform what `next()` returns, or return without calling `next()` and short-circuit the rest of the chain — what the Cordis docs call the veto. Create `waterfall-demo.ts`: + +```ts +import type { Context } from 'cordis' + +declare module 'cordis' { + interface Events { + 'demo/transform'(input: string, next: () => Promise): Promise + } +} + +export const name = 'waterfall-demo' + +export function apply(ctx: Context) { + // Listener 1: wrap the downstream result. + ctx.on('demo/transform', async (input, next) => { + const downstream = await next() + return downstream.toUpperCase() + }) + + // Listener 2: short-circuit when it owns the decision. + ctx.on('demo/transform', async (input, next) => { + if (input.includes('blocked')) return '** blocked **' + return next() + }) + + void (async () => { + console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello')) + console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words')) + })() +} +``` + +Point `cordis.yml` at just this file and run: + +``` +HELLO +** BLOCKED ** +``` + +Walk through the second line: listener 1 runs first, calls `next()`, which invokes listener 2; listener 2 sees `blocked` and returns without calling `next()` — the innermost default (the function passed to `ctx.waterfall`) never runs — and listener 1 uppercases the replacement message on the way out. + +The discipline that follows: **a waterfall listener that only observes or annotates must call `next()`**; returning without it is a deliberate short-circuit. Forgetting `next()` in a logging listener silently swallows the default behavior for everyone downstream. This is important enough that it is a standing rule of this repository ([waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)). + +The harness uses waterfalls for decisions that cooperating plugins may wrap or answer: [`agent/request`](../cordis-catalog/events.md#agentrequest--waterfall) lets a plugin replace the model-call config, and [`approval/request`](../cordis-catalog/events.md#approvalrequest--waterfall) lets a policy answer instead of the user. + +Next: [Configuration](05-config.md) — plugin options from `cordis.yml`. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md new file mode 100644 index 0000000000..09aaf8971d --- /dev/null +++ b/docs/cordis-tutorial/05-config.md @@ -0,0 +1,82 @@ +# 5. Configuration + +Each `cordis.yml` entry can carry a `config` block, and the plugin declares a schema that validates it before `apply` runs. Bad config fails the load with a precise error — the plugin never starts half-configured. + +## A configurable plugin + +Create `config-demo.ts` in `tmp/cordis-tutorial`: + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' + +export const name = 'config-demo' + +export interface Config { + greeting: string + targets: string[] +} + +export const Config: Schema = Schema.object({ + greeting: Schema.string().default('Hello'), + targets: Schema.array(String).default(['world']), +}) + +export function apply(ctx: Context, config: Config) { + for (const target of config.targets) { + console.log(`${config.greeting}, ${target}!`) + } +} +``` + +The exported `Config` is both a TypeScript interface and a runtime schema with the same name — consumers get the type, Cordis gets the validator. This repo uses [Schemastery](https://github.com/shigma/schemastery) for schemas; Cordis itself accepts any [Standard Schema](https://standardschema.dev/) validator, so a plain object exported as `Config` will not work. + +Configure it: + +```yaml +- name: './config-demo.ts' + config: + targets: ['alpha', 'beta'] +``` + +Run: + +``` +Hello, alpha! +Hello, beta! +``` + +`greeting` was omitted, so the schema default filled it in — `apply` always receives complete, validated config. + +## Fail loud + +Now feed it something invalid: + +```yaml +- name: './config-demo.ts' + config: + targets: 'not-an-array' +``` + +``` +ValidationError: invalid config: + - $.targets expected array but got not-an-array (at targets) +``` + +The plugin's fiber goes to FAILED, and this tutorial's launcher exits with status 1 after printing the error. A plugin should also reject schema-valid config that names an unavailable resource or provider as soon as it can resolve that reference. + +## Computed config values + +The loader used in this repo supports a `!!js` tag for config values that must be computed at load time, such as reading an API key from the environment: + +```yaml +- name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY +``` + +`!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration). + +Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md new file mode 100644 index 0000000000..b11fdbe45c --- /dev/null +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -0,0 +1,111 @@ +# 6. Composition and HMR + +Every capability built so far is a plugin, and `cordis.yml` selects the application's plugin tree. This chapter changes that composition, hot-reloads a plugin, and diagnoses a plugin that never loads. + +## Entries are more than a name + +A config entry accepts metadata beyond `name` and `config`: + +```yaml +- id: greeter # stable identity for this entry + name: './greeter.ts' +- id: consumer + name: './consumer.ts' + disabled: true # keep the entry, skip mounting it +``` + +`id` gives the entry a stable identity so the loader can tell an edit to an existing entry apart from a removal plus an addition. `disabled: true` unmounts a plugin without deleting its entry — flip it back and the plugin (and everything PENDING on its services) loads again. + +Groups nest a sub-list of entries that load and unload as one unit, and `isolate` gives a group its own instance of a service name — two groups can each see a differently-configured `bash` without affecting each other. Those are worth knowing about before you need them; the [Cordis primer](../cordis-primer.md) and the [service isolation example](../user/develop/framework/service.md#service-isolation) cover the details. + +## Hot module replacement + +Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@cordisjs/plugin-hmr` plugin watches your files and does exactly that on save. + +In `tmp/cordis-tutorial`, write `cordis.yml`: + +```yaml +- id: logger + name: '@cordisjs/plugin-logger-console' +- id: timer + name: '@cordisjs/plugin-timer' +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] +- id: hello + name: './hello.ts' +``` + +Two support plugins joined the list: HMR logs through the Cordis logger service, so without a console exporter you would not see its messages, and it `inject`s the `timer` service for debouncing — without `@cordisjs/plugin-timer` it sits in PENDING forever, silently. That silence is the subject of the next section. + +HMR also needs Node's loader internals: + +```sh +node --expose-internals --import tsx ../../vendor/cordis/bin.js +``` + +Now edit `hello.ts` — change the log message — and save: + +``` +hello from my first plugin +2026-07-22 15:44:36 [I] hmr watching [ '.' ] +2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts +hello from my EDITED plugin +``` + +The old instance unloaded (all its effects unwound), the new code loaded, `apply` ran again. Stop the process with Ctrl-C. Editing `cordis.yml` itself is also picked up: the loader diffs entries by `id` and mounts, unmounts, or reconfigures only what changed. This is why the entries above carry explicit `id`s — an entry without one gets a generated id on every read, so after any config-file edit it counts as removed-plus-added and remounts even if its own lines did not change. + +## Diagnosing a plugin that never loads + +The flip side of dependency-driven loading: a plugin whose `inject` names a service nobody provides waits forever, printing nothing. No error — PENDING is a legitimate state, since the provider may be mounted later. + +You can see the states directly. Every context can enumerate the plugin registry; create `diagnose.ts`: + +```ts +import { FiberState, type Context } from 'cordis' + +export const name = 'diagnose' + +export function apply(ctx: Context) { + setTimeout(() => { + for (const runtime of ctx.registry.values()) { + for (const fiber of runtime.fibers) { + if (fiber.state === FiberState.PENDING) { + console.log(`${fiber.name} is PENDING — a required service is missing`) + } + } + } + }, 500) +} +``` + +And a plugin with an unsatisfiable dependency, `needs-timer.ts`: + +```ts +import type { Context } from 'cordis' + +export const name = 'needs-timer' +export const inject = ['timer'] + +export function apply(ctx: Context) { + console.log('needs-timer loaded') +} +``` + +```yaml +- name: './needs-timer.ts' +- name: './diagnose.ts' +``` + +Run it (plain `node --import tsx ../../vendor/cordis/bin.js`; stop with Ctrl-C): + +``` +needs-timer is PENDING — a required service is missing +``` + +`inject: ['timer']` has no provider. Add `- name: '@cordisjs/plugin-timer'` to the list and the plugin loads. When a plugin does nothing and reports nothing, inspect its fiber state. Iterating without the PENDING filter also shows the loader's own plugins (Loader, Include) as ACTIVE fibers because plugins mount the config file itself. + +Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md new file mode 100644 index 0000000000..d25419afd5 --- /dev/null +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -0,0 +1,101 @@ +# 7. Into the harness + +This chapter registers a model-callable tool with the harness's `tools` service, executes it through the harness tool pipeline, and observes the result event. It remains keyless and does not call a model. + +## A tool plugin + +Create `greet-tool.ts` in `tmp/cordis-tutorial`: + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { CallId } from '@deepseek-ai/dsh-llm' + +export const name = 'greet-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'greet', + description: 'Greet the named person.', + parameters: { + name: { type: 'string', required: true, description: 'Who to greet' }, + }, + async execute(args) { + return [{ type: 'text', text: `Hello, ${args.name}!` }] + }, + })) + + // Drive one call through the real execution pipeline, standing in for + // the model. CallId brands the correlation id a provider would issue. + void (async () => { + const result = await ctx.tools.execute({ + callId: CallId('demo-1'), + name: 'greet', + arguments: { name: 'Cordis' }, + signal: new AbortController().signal, + }) + console.log('tool replied:', JSON.stringify(result.content)) + })() +} +``` + +Every pattern here is from the earlier chapters: `inject: ['tools']` ([chapter 3](03-services.md)) holds the plugin until the tool registry exists; `ctx.tools.register(...)` attaches the registration disposer to the plugin ([chapter 2](02-lifecycle-and-effects.md)), so unloading unregisters the tool. `defineTool` converts the `parameters` spec to the JSON Schema shown to the model, infers the type of `args`, and validates model-supplied arguments before `execute` runs. + +## An observer plugin + +Create `tool-logger.ts` — a separate plugin that watches every tool call in the app through the harness's `tools/result` event: + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tools' + +export const name = 'tool-logger' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.on('tools/result', (exec, result) => { + const text = result.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') + console.log(`[tool-logger] ${exec.name} -> ${text}`) + }) +} +``` + +The `import type {} from '@deepseek-ai/dsh-tools'` line pulls in the package's declaration merges so `'tools/result'` and its payload are typed — the same move as chapter 4's `stats.ts` import, at package scale. + +## Compose and run + +```yaml +- name: '@deepseek-ai/dsh-system-prompt' +- name: '@deepseek-ai/dsh-tools' +- name: './tool-logger.ts' +- name: './greet-tool.ts' +``` + +`@deepseek-ai/dsh-tools` injects the `systemPrompt` service because tools contribute schemas to the system prompt, so the composition lists its provider too. Without it, the tools plugin remains PENDING as described in [chapter 6](06-composition-and-hmr.md). + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +``` +[tool-logger] greet -> Hello, Cordis! +tool replied: [{"type":"text","text":"Hello, Cordis!"}] +``` + +The logger fired first: `tools/result` is emitted as part of result materialization, before `execute`'s promise resolves to the caller. Neither of your plugins knows the other exists — the registry service and the event connect them. + +## From here to a full agent + +A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, a front end. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file. + +Where to go next: + +- [Build a tool](../user/develop/basic/tool.md) — more of `defineTool`, including presentation and richer schemas. +- [Three-layer capability design](../user/develop/practice/index.md) — how the harness structures replaceable capabilities. +- The generated [services](../cordis-catalog/services.md) and [events](../cordis-catalog/events.md) catalogs — everything you can inject and listen to. +- [Architecture](../architecture.md) — the system map these plugins live in. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md new file mode 100644 index 0000000000..9cf3966117 --- /dev/null +++ b/docs/cordis-tutorial/index.md @@ -0,0 +1,54 @@ +# Cordis tutorial + +Cordis is the plugin framework underneath the DeepSeek Harness SDK: a small runtime where every capability — tools, LLM adapters, file access, the agent loop itself — is a plugin mounted into a shared context. This tutorial teaches Cordis hands-on: each chapter is a runnable example you build in a scratch directory inside this repository, ending with a plugin wired into real harness services. + +The audience is agent developers. You do not need deep TypeScript experience; the [TypeScript notes](#typescript-notes) below explain the syntax that may be unfamiliar, and every chapter shows the exact commands and expected output. + +If you want the condensed concept reference instead of a walkthrough, read the [Cordis primer](../cordis-primer.md). The exhaustive API reference lives in the generated [events](../cordis-catalog/events.md) and [services](../cordis-catalog/services.md) catalogs and the [Cordis core API](../cordis-catalog/core/context.md) pages. + +## Setup + +You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly. + +```sh +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness +pnpm install +``` + +Create the scratch directory the chapters work in. `tmp/` is gitignored, so nothing you write there touches version control: + +```sh +mkdir -p tmp/cordis-tutorial +cd tmp/cordis-tutorial +``` + +Every chapter runs the same command from this directory: + +```sh +node --import tsx ../../vendor/cordis/bin.js +``` + +That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js)) creates a root `Context`, mounts the Loader plugin, and tells it to load `./cordis.yml` from the current directory. Everything else — which plugins exist, how they are configured — comes from that YAML file, which you will write in a moment. The `--import tsx` flag lets Node run the TypeScript files the config points at without a build step. + +## Chapters + +1. [Your first plugin](01-first-plugin.md) — a plugin is a function; the loader mounts it. +2. [Lifecycle and effects](02-lifecycle-and-effects.md) — Cordis-managed registrations are undone when their plugin unloads. +3. [Services](03-services.md) — expose a capability on `ctx` and depend on it with `inject`. +4. [Events](04-events.md) — typed events, broadcast dispatch, and the waterfall short-circuit. +5. [Configuration](05-config.md) — validated config from `cordis.yml`, failing loud on bad input. +6. [Composition and HMR](06-composition-and-hmr.md) — the config file as a plugin tree, hot reload, and diagnosing a plugin that never loads. +7. [Into the harness](07-into-the-harness.md) — register a model-callable tool against real harness services. + +## TypeScript notes + +The examples use three TypeScript features beyond ordinary modern JavaScript: + +- **Type annotations** describe values without changing runtime behavior: `ctx: Context` says that `ctx` has the Cordis context API, `who: string` accepts text, and `string[]` means an array of strings. +- **`import type { Context } from 'cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency. +- **Declaration merging** (`declare module 'cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full. + +Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. + +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 2070c464cf..b639e55927 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -67,8 +67,8 @@ interface BashExecRequest { * reject non-`DSH_*` names supplied through this managed channel. */ dshEnv?: DshEnvironment | undefined - /** Explicit per-call sandbox mode override. */ - sandboxMode?: SandboxMode | undefined + /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ + sandboxPolicy?: SandboxExecutionPolicy | undefined } ``` @@ -100,8 +100,8 @@ interface BashExecSpec { env?: Record | undefined /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ dshEnv?: DshEnvironment | undefined - /** Resolved sandbox mode; ignored by executors that do not confine. */ - sandboxMode: SandboxMode | undefined + /** Resolved sandbox policy; ignored by executors that do not confine. */ + sandboxPolicy: SandboxExecutionPolicy | undefined } ``` @@ -159,7 +159,7 @@ interface CollectedOutput { ## File sandbox: `BashSandboxInfo` -A sandbox-consuming executor exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each session's durable `sandbox/mode` override (owned by [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md)) and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only. +A sandbox-consuming executor exposes its configured mode fallback through `BashExecutor.sandboxMode`. The tool layer asks [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md) to resolve each calling session's durable `sandbox/mode` override and immutable cwd into `BashExecRequest.sandboxPolicy`; a user-approved strictly wider call replaces only the mode. The mode/root/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only. A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index bb302ff52b..6fa281a96c 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -58,7 +58,7 @@ Automatic callers state why policy is running; implementations may treat confirm type CompactionTrigger = 'pressure' | 'context-overflow' ``` -`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. +`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Every backend marks its replacement `user/message` with the package-exported `COMPACT_CHECKPOINT_SOURCE`; consumers call `isCompactCheckpointSource()` instead of coupling checkpoint recognition to one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and authorizes a fresh numbered-step retry only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c547c92761..6b5596b782 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -29,7 +29,8 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | -| [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors | +| [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots | +| [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | | [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | | [lsp.md](lsp.md) | the LSP navigation seam: `LspQueryRequest`/`Result`, `LspProvider`/`Service`, four operations, `LspError` | @@ -358,11 +359,27 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`InjectOptions` extends ordinary message attribution with durable model-hidden JSON metadata: +```ts type-equiv +/** + * Message options. An omitted source attests direct human input as `{ kind: 'user' }` + * and may authorize policy consumers, so non-human producers must label their content. + */ +interface SendOptions { + source?: MessageSource + /** + * Model-facing contexts captured with this inbox item. A queued prompt exposes + * them through the default `agent/prompt-submit` allow decision, while steering + * records them directly at its next checkpoint. + */ + contexts?: HookContext[] +} +``` + +`InjectOptions` accepts ordinary message attribution and durable model-hidden JSON metadata. Attached contexts belong only to queued or steering input, so synthetic injection cannot accept them: ```ts type-equiv /** Options specific to durable synthetic context injection. */ -interface InjectOptions extends SendOptions { +interface InjectOptions extends Omit { /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } @@ -390,7 +407,8 @@ interface Agent { * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole * ordinary message in its FIFO-ordered turn; the next claimed item waits for * that turn's checkpoint. - * Invalid input throws synchronously before notification or enqueue. + * Attached contexts share the same snapshot and ownership boundary. Invalid + * input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -442,15 +460,21 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which is `inject()`ed as a `context/message` and therefore carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as a user-role message, while JSON `meta` persists plugin state without exposing it to the model. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance and metadata. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. +Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes `context/message`; `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv -/** Model-facing context injected by a listener; `source` prevents plugin text from being labeled as user input. */ +/** Model-facing context injected by a listener or atomically attached to one inbox message. */ interface HookContext { content: ContentBlock[] source: MessageSource + /** + * Model placement. Absent or `separate` records an independent + * `context/message`; `prompt-prefix` prepends this context and a stable + * request delimiter to the same user-role message as its attached prompt. + */ + placement?: 'separate' | 'prompt-prefix' /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } @@ -460,10 +484,13 @@ interface HookContext { ```ts type-equiv /** - * Prompt interception result. `allow.content` replaces the prompt and each - * `additionalContexts` entry becomes a separate context message. `block` - * records a durable `prompt/blocked` and ends the claimed prompt's zero-step - * turn as rejected. + * Prompt interception result. `allow.content` replaces the prompt. Each + * `additionalContexts` entry follows its declared placement: separate context + * message by default, or a prefix inside the prompt's user-role message. + * `block` records a durable `prompt/blocked` and ends the claimed prompt's + * zero-step turn as rejected. An `allow` returned by a listener is + * authoritative: a listener wrapping `next()` preserves downstream `content` + * and `additionalContexts` unless it intentionally replaces them. */ type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } diff --git a/docs/core-data-structures/pty.md b/docs/core-data-structures/pty.md new file mode 100644 index 0000000000..c8dfa49460 --- /dev/null +++ b/docs/core-data-structures/pty.md @@ -0,0 +1,89 @@ +# Persistent PTY Sessions + +Types shared by PTY backends, `ctx.pty`, and the model-facing consumer. The [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) owns the rationale; this page records the cross-package vocabulary from [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts). + +## Identity and readiness + +`PtySessionId` is a service-minted branded id. Optional names are owner-local display metadata; authorization compares the exact owning `Agent`, not a name or guessed id. + +`PtyWaitReason` says why one send returned. It is independent from `PtySessionStatus`: silence or timeout may return while the top-level shell remains alive, while `session_exit` means that shell exited rather than an arbitrary foreground child. + +```ts type-equiv +/** Why one interactive send returned control to its caller. */ +type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit' +``` + +```ts type-equiv +/** Top-level PTY process status, independent of a send's wait reason. */ +type PtySessionStatus = + | { kind: 'running' } + | { kind: 'exited'; exitCode: number | null; signal: NodeJS.Signals | null } +``` + +## Backend and live session + +A backend owns how one registered type starts and detects readiness. `PtyService` publishes the returned session only after setup succeeds, then owns id authorization and cleanup. A backend session owns terminal state and captured-resource quiescence. + +```ts type-equiv +/** Replaceable provider for one PTY session type. */ +interface PtyBackend { + /** Stable type selected by {@link PtySpawnRequest.type}. */ + readonly type: string + /** Create an unpublished session or reject after cleaning partial resources. */ + spawn(spec: PtyBackendSpawnSpec): Promise +} +``` + +```ts type-equiv +/** Backend-owned live session retained by {@link PtyService}. */ +interface PtyBackendSession { + /** Initial bounded terminal output returned from `terminal_open`. */ + readonly motd: string + /** Top-level process id when one exists. */ + readonly pid?: number + /** Start one exclusive send operation. */ + startSend(request: PtySendRequest): PtySendOperation + /** Read one bounded page from retained scrollback. */ + read(request: PtyReadRequest): PtyReadResult + /** Signal the verified foreground process group. */ + signal(signal: PtySignal): Promise + /** Observe top-level process status. */ + status(): PtySessionStatus + /** Idempotently close the captured owned process tree and await quiescence. */ + close(reason: string): Promise +} +``` + +## Send and retained output + +One live session accepts one active send. Its operation exposes a consuming output cursor for generic background tasks and one terminal result for a foreground caller. `PtyReadResult` separately pages the bounded session scrollback. + +```ts type-equiv +/** Live backend-owned send; exactly one may be active per PTY session. */ +interface PtySendOperation { + /** Resolves after readiness, timeout, cancellation, or top-level process exit. */ + done: Promise + /** Consume output produced since the prior call. */ + readOutput(): PtySendRead + /** Request `SIGINT`; returns false after the operation settled. */ + cancel(): boolean +} +``` + +```ts type-equiv +/** Settled result for one foreground or background send. */ +interface PtySendResult { + /** Bounded rendered terminal delta remaining at settlement. */ + viewport: string + /** Why the wait returned; this does not imply arbitrary child-process exit. */ + waitReason: PtyWaitReason + /** Top-level session status observed at settlement. */ + sessionStatus: PtySessionStatus + /** Whether output was dropped from the operation or retained scrollback. */ + truncated: boolean +} +``` + +## Ownership and durability + +`PtyService` attaches one awaited cleanup to the exact owner scope, rejects foreign operations, and keeps sessions alive across backend or tool-plugin reload. PTY state and raw bytes remain process-local. Model input and bounded returned output are durable through the existing `tool/call`, `tool/result`, and task-result paths rather than duplicate PTY session events. diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 5b20febc3e..bdc86287fb 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -38,7 +38,35 @@ type SandboxEnforcement = 'full' | 'partial' ## Per-call policy -The policy is fully resolved and carried per call. This permits concurrent consumers and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state. +The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. + +```ts type-equiv +/** + * The complete file-effect policy resolved for one capability call. The root + * is carried even under modes that do not consume it so callers can resolve + * policy once before choosing the enforcement path. + */ +interface SandboxExecutionPolicy { + /** The file-effect mode this execution runs under. */ + mode: SandboxMode + /** Absolute root directory `workspace-write` may write under. */ + workspaceRoot: string +} +``` + +`ctx.sandboxPolicy.resolve()` accepts the active session and, for an approved retry, an explicit mode. The service owns precedence and root fallback so bash and fs do not repeat it. + +```ts type-equiv +/** Inputs that select the sandbox policy for one capability call. */ +interface SandboxPolicyRequest { + /** Calling session; its immutable cwd becomes the workspace boundary. */ + session?: Session + /** Explicit approved mode override, which outranks session policy. */ + mode?: SandboxMode +} +``` + +Only a confined execution reaches `ctx.sandbox`; its provider policy narrows the mode while retaining the same root. This permits concurrent sessions, consumers, and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state. ```ts type-equiv /** @@ -46,15 +74,12 @@ The policy is fully resolved and carried per call. This permits concurrent consu * fixed on the provider: two consumers may confine under different policies * at the same instant (bash under `read-only` while a confined child agent * needs its state directory writable), and an approved escalated retry is a - * new call with a wider policy. Defaulting/resolution is the consumer's - * explicit step (its config owns the fallback chain); the provider treats - * the policy as fully specified. + * new call with a wider policy. Defaulting/resolution is an explicit step at + * the consumer boundary; the provider treats the policy as fully specified. */ -interface SandboxPolicy { +interface SandboxPolicy extends SandboxExecutionPolicy { /** The file-effect mode this execution runs under. */ mode: ConfinedSandboxMode - /** Absolute root directory `workspace-write` may write under. */ - workspaceRoot: string } ``` diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 4652358162..8b37b0f5f2 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -25,6 +25,20 @@ interface SessionRecord { } ``` +`SessionSurfaceSnapshot` is one exact-read observation rather than a retained subscription. Its raw-log boundary and folded events come from the same live-preferred load. + +```ts type-equiv +/** One atomic live-preferred observation of a session's current model surface. */ +interface SessionSurfaceSnapshot { + /** Cloned session header selected from the same corpus observation as `events`. */ + session: SessionHeader + /** Highest raw-log seq included in the observation, or `null` for an empty log. */ + capturedThroughSeq: number | null + /** Cloned current surface events in model-history order. */ + events: SurfaceEvent[] +} +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md new file mode 100644 index 0000000000..dbe3c43d35 --- /dev/null +++ b/docs/core-data-structures/session-reference.md @@ -0,0 +1,65 @@ +# Session References + +Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) owns canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. + +Source: [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) + +## Inputs and candidates + +`SessionReferenceInput` is the host-independent selection. The id is authoritative; the label is display metadata carried into the snapshot. + +```ts type-equiv +/** One source session selected by a host. */ +interface SessionReferenceInput { + /** Opaque source session identity. */ + sessionId: SessionId + /** Optional user-facing mention label. */ + label?: string +} +``` + +`SessionReferenceCandidate` is host-facing discovery output. Its label uses the latest session title when present, while filtering still searches only session id and cwd and never transcript text. + +```ts type-equiv +/** One host-facing candidate from exact session metadata. */ +interface SessionReferenceCandidate { + /** Opaque source session identity. */ + sessionId: SessionId + /** Latest log-backed title, falling back to the opaque session id. */ + label: string + /** Source session working directory, when recorded. */ + cwd?: string + /** Source session creation time in Unix epoch milliseconds. */ + createdAt: number +} +``` + +## Prepared messages + +Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `send()` or `steer()` call. + +```ts type-equiv +/** Message payload and the zero-or-one durable snapshot contexts bound to it. */ +interface PreparedReferencedMessage { + /** Readable message content after host mention tokens are removed. */ + content: ContentBlock[] + /** Empty without references; otherwise one aggregated untrusted context. */ + contexts: HookContext[] +} +``` + +## Errors + +`SessionReferenceError.code` separates invalid configuration or input, self-reference, count limits, source-read failure, budget failure, and cancellation. Host protocols map these codes to their own error envelopes without inspecting prompt bytes. + +```ts type-equiv +/** Stable failure codes exposed to host adapters. */ +type SessionReferenceErrorCode = + | 'SESSION_REFERENCE_INVALID_CONFIG' + | 'SESSION_REFERENCE_INVALID_REFERENCE' + | 'SESSION_REFERENCE_SELF_REFERENCE' + | 'SESSION_REFERENCE_TOO_MANY' + | 'SESSION_REFERENCE_READ_FAILED' + | 'SESSION_REFERENCE_BUDGET_EXCEEDED' + | 'SESSION_REFERENCE_CANCELLED' +``` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index e115de01ec..7f61be2e73 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -8,6 +8,18 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. +```ts type-equiv +/** Shared payload for ordinary and steering prompt messages. */ +interface PromptMessageData { + /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ + content: ContentBlock[] + /** Producer provenance for the direct prompt. */ + source: MessageSource + /** Present only when prompt-prefix contexts were baked into `content`. */ + envelope?: PromptMessageEnvelope +} +``` + ```ts type-equiv /** * The merge-extensible, append-only source of truth for an agent interaction. @@ -35,7 +47,7 @@ interface SessionEventMap { /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } /** A user-visible prompt (the queued message claimed for this turn). */ - 'user/message': { content: ContentBlock[]; source: MessageSource } + 'user/message': PromptMessageData /** * Durable record of a prompt veto and its reason. It is log-only: the blocked * prompt never enters the model-visible surface, and its turn runs zero steps. @@ -83,7 +95,7 @@ interface SessionEventMap { */ 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } /** Steering content injected between steps of a running turn. */ - 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } + 'steering/message': PromptMessageData & { turn: number } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** @@ -94,6 +106,8 @@ interface SessionEventMap { } ``` +`PromptMessageData.content` is always the exact model-facing content. When attached context declares `prompt-prefix` placement, AgentLoop concatenates its blocks, a `## My request:` delimiter, and the effective direct prompt into that array. The optional model-hidden `envelope` retains `displayContent` plus ordered prefix-context source/metadata descriptors, so transcript, title, and re-reference consumers can present the human prompt without changing reconstructable history. `displayPromptContent()` performs that selection and falls back to `content` for ordinary and older events. + ### `OutOfBandSessionEventMap` — narrow late-append opt-in `SessionEventMap` membership alone does not authorize an event outside the agent loop's ordinary lifecycle. An event owner declaration-merges the same key into this empty marker map before `ctx.sessions.appendOutOfBand()` accepts it; the derived type additionally excludes every surface event. An accepted update joins an open turn or receives a balanced, flushed zero-step turn. @@ -438,11 +452,11 @@ declare class Session { `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: -- `user/message` → a user message. +- `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata. - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. - `context/message` → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. -- `steering/message` → a user-role message carrying its content verbatim at its chronological position. +- `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. @@ -528,6 +542,6 @@ The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepse ## Durability contract -What a persistence backend relies on: the durable log persists every event verbatim, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format. +What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's opt-in packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format. The backends that consume this contract are on [persistence.md](persistence.md). diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 20eb3b4ca1..40828094ce 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 -development.md: 10406cebae1bf83fff663903b1478c9acb8476a1 -development.zh.md: 50051ffd631518b37c3ad96f5fd3830cf6893ec9 +development.md: 4294038e40aa774a006874e6641ca63eea44beeb +development.zh.md: 1f07c95dd60d0554b945c29e6e3ba8bc6ca9841a diff --git a/docs/development.md b/docs/development.md index 10406cebae..4294038e40 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,7 +33,26 @@ Run typecheck once after a fresh clone: pnpm run typecheck ``` -That first typecheck runs the package/vendor build graph and the root no-emit `tsconfig.json` graph for examples, tests, and scripts. The root graph uses the same source `paths` map but relies on project references so vendored code is checked under its own tsconfig settings. +That first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below. + +## TypeScript project layout + +The repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them. + +| File | Role | Forms a program? | +|---|---|---| +| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No | +| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes | +| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes | +| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | +| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No | + +Host and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow: + +- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope. +- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need. + +Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). If a relevant local check consumes built package output, build once first: @@ -59,12 +78,14 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: - `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. -- `pre-push` runs only the incremental repository typecheck. +- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates). The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. The hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix. +Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction. + ## CI gates The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. @@ -77,7 +98,8 @@ Use these from the repo root: pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts +pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks +pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs diff --git a/docs/development.zh.md b/docs/development.zh.md index 50051ffd63..1f07c95dd6 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -33,7 +33,26 @@ pnpm exec lefthook install --force pnpm run typecheck ``` -首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。 +首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。 + +## TypeScript 项目布局 + +仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。 + +| 文件 | 角色 | 是否构成 program? | +|---|---|---| +| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 | +| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 | +| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 | +| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | +| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 | + +host 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律: + +- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。 +- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。 + +静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: @@ -59,12 +78,14 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: - `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; -- `pre-push` 只运行仓库增量类型检查。 +- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。 +贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。 + ## CI 门禁 keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 @@ -77,7 +98,8 @@ keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若 pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts +pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks +pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d7f4123b91..a04e633377 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,33 +8,33 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:201`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:257`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:333`](../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) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:330`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:352`](../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/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `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) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`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:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:90`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`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), `runtime`, [`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/flush` | `parallel` | [`packages/core/session/src/index.ts:100`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`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), `runtime`, [`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/ui/acp), [`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), `runtime`, [`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/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) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -42,7 +42,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index ed5e11937f..24fc34b890 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -98,6 +98,7 @@ flowchart TD pkg_hooks_codex["hooks-codex"] end subgraph group_session_persistence["packages/session-persistence"] + pkg_session_checkpoint_policy["session-checkpoint-policy"] pkg_session_persistence["session-persistence"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] @@ -148,6 +149,7 @@ flowchart TD pkg_code_runtime_worker["code-runtime-worker"] end subgraph group_context["packages/context"] + pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_workspace_context["workspace-context"] end @@ -174,6 +176,11 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_pty["packages/pty"] + pkg_pty["pty"] + pkg_pty_local["pty-local"] + pkg_tool_pty["tool-pty"] + end subgraph group_sandbox["packages/sandbox"] pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] @@ -372,6 +379,9 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_pty --> pkg_agent + pkg_pty --> pkg_brand + pkg_pty --> pkg_invariants pkg_scripts --> pkg_app_boot pkg_scripts --> pkg_invariants pkg_tasks --> pkg_agent @@ -426,6 +436,17 @@ flowchart TD pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session pkg_permission --> pkg_user_approval + pkg_session_reference --> pkg_agent + pkg_session_reference --> pkg_compact + pkg_session_reference --> pkg_invariants + pkg_session_reference --> pkg_llm + pkg_session_reference --> pkg_retention + pkg_session_reference --> pkg_session + pkg_session_reference --> pkg_session_query + pkg_pty_local --> pkg_invariants + pkg_pty_local --> pkg_pty + pkg_pty_local --> pkg_sandbox + pkg_pty_local --> pkg_sandbox_policy pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -518,6 +539,12 @@ flowchart TD pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools + pkg_session_checkpoint_policy --> pkg_agent + pkg_session_checkpoint_policy --> pkg_invariants + pkg_session_checkpoint_policy --> pkg_llm + pkg_session_checkpoint_policy --> pkg_session + pkg_session_checkpoint_policy --> pkg_session_persistence + pkg_session_checkpoint_policy --> pkg_tools pkg_agent_loop_testkit --> pkg_agent pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm @@ -547,6 +574,13 @@ flowchart TD pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_tools + pkg_tool_pty --> pkg_agent + pkg_tool_pty --> pkg_invariants + pkg_tool_pty --> pkg_llm + pkg_tool_pty --> pkg_pty + pkg_tool_pty --> pkg_system_prompt + pkg_tool_pty --> pkg_tasks + pkg_tool_pty --> pkg_tools pkg_tool_tasks --> pkg_agent pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_system_prompt @@ -596,6 +630,8 @@ flowchart TD pkg_acp --> pkg_sandbox pkg_acp --> pkg_session pkg_acp --> pkg_session_persistence + pkg_acp --> pkg_session_query + pkg_acp --> pkg_session_reference pkg_acp --> pkg_session_title pkg_acp --> pkg_system_prompt pkg_acp --> pkg_tools @@ -616,6 +652,7 @@ flowchart TD pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session pkg_tui --> pkg_session_persistence + pkg_tui --> pkg_session_reference pkg_tui --> pkg_session_title pkg_tui --> pkg_skill pkg_tui --> pkg_system_prompt @@ -672,7 +709,10 @@ flowchart TD pkg_acp_demo --> pkg_command_goal pkg_acp_demo --> pkg_commands 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_reference pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction pkg_acp_demo --> pkg_workspace_context @@ -682,6 +722,7 @@ flowchart TD pkg_cli_demo --> pkg_invariants pkg_cli_demo --> pkg_llm pkg_cli_demo --> pkg_session + pkg_cli_demo --> pkg_session_checkpoint_policy pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context @@ -694,7 +735,10 @@ flowchart TD pkg_tui_demo --> pkg_invariants pkg_tui_demo --> pkg_llm pkg_tui_demo --> pkg_session + pkg_tui_demo --> pkg_session_checkpoint_policy pkg_tui_demo --> pkg_session_persistence_jsonl + pkg_tui_demo --> pkg_session_query + pkg_tui_demo --> pkg_session_reference pkg_tui_demo --> pkg_tool_ask_user pkg_tui_demo --> pkg_tools pkg_tui_demo --> pkg_tui @@ -777,6 +821,7 @@ flowchart TD | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -788,6 +833,8 @@ flowchart TD | [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | +| [`pty-local`](../packages/pty/pty-local) | `pty` | [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | @@ -802,26 +849,28 @@ flowchart TD | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`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) | | [`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) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | +| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`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/ui/acp), [`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), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`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-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-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`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) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`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), [`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-reference`](../packages/context/session-reference), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`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-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/persistence-catalog.md b/docs/persistence-catalog.md index 7720d92ed8..b5a3c60463 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:351`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +151,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +167,7 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) ### `compact/*` @@ -246,7 +246,7 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `hook/*` @@ -357,7 +357,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `request/*` @@ -371,7 +371,7 @@ Source: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -405,7 +405,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [SessionTitleEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:95`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:96`](../packages/session-title/session-title/src/index.ts) #### `session/title-llm-request` — log-only @@ -424,12 +424,10 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages ```ts persistence-catalog /** Steering content injected between steps of a running turn. */ -'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } +'steering/message': PromptMessageData & { turn: number } ``` -Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) - -Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) ### `step/*` @@ -440,7 +438,7 @@ Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -449,7 +447,7 @@ Source: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) ### `todo/*` @@ -462,7 +460,7 @@ Source: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) ### `tool/*` @@ -479,7 +477,7 @@ Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -523,7 +521,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) ### `turn/*` @@ -541,7 +539,7 @@ Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -557,7 +555,7 @@ Source: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) ### `user/*` @@ -565,9 +563,7 @@ Source: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/ ```ts persistence-catalog /** A user-visible prompt (the queued message claimed for this turn). */ -'user/message': { content: ContentBlock[]; source: MessageSource } +'user/message': PromptMessageData ``` -Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) - -Source: [`packages/core/session/src/types.ts:209`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) diff --git a/docs/testing.md b/docs/testing.md index 85601ab6c9..85798cee3e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -29,6 +29,10 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. - "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +## Test resolution: source plane only + +- Every vitest config points vite-tsconfig-paths at `tsconfig.base.json`; bare workspace imports resolve to `src` ([layout](development.md#typescript-project-layout)), never through package `exports` to built `lib/` — stale artifacts there load a second copy of module singletons. Built artifacts are consumed only explicitly: `lib`-mode subprocesses and the built smokes below. + ## Test subprocess launch modes - CI and build-having test lanes run every example or Cordis-config subprocess from built `lib/` through the shared dual-mode launcher. Do not hand-write `--import tsx` for these subprocesses. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 817a67863a..5c1bfbfdf3 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,12 +22,13 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | +| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@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-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`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent 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-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/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 or ACP plan. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -422,6 +423,169 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. +## `@deepseek-ai/dsh-tool-pty` + +### `terminal_close` + +Close one persistent terminal and wait until its captured owned process tree is gone. + +```json +{ + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Terminal session id." + } + }, + "required": [ + "sessionId" + ] +} +``` + +Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts) + +### `terminal_list` + +List persistent terminal sessions owned by the current agent. + +```json +{ + "type": "object", + "properties": {} +} +``` + +Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts) + +### `terminal_open` + +Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls. + +```json +{ + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Registered terminal backend type, usually \"shell\"." + }, + "name": { + "type": "string", + "description": "Optional owner-local display name such as \"main\" or \"gdb\"." + }, + "cwd": { + "type": "string", + "description": "Initial working directory. Defaults to the deployment workspace root." + } + }, + "required": [ + "type" + ] +} +``` + +Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts) + +### `terminal_read` + +Read a bounded page of retained output from a persistent terminal without sending input. + +```json +{ + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Terminal session id." + }, + "offset": { + "type": "number", + "description": "Newest-relative line offset (default 0)." + }, + "count": { + "type": "number", + "description": "Requested line count (default 500; backend caps apply)." + } + }, + "required": [ + "sessionId" + ] +} +``` + +Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts) + +### `terminal_send` + +Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill. + +```json +{ + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Terminal session id returned by terminal_open or terminal_list." + }, + "text": { + "type": "string", + "description": "UTF-8 text to write to the terminal." + }, + "submit": { + "type": "boolean", + "description": "Submit Enter after text (default true). Set false for control characters or incomplete REPL input." + }, + "run_in_background": { + "type": "boolean", + "description": "Return a task id immediately; collect with task_output or stop with task_kill." + } + }, + "required": [ + "sessionId", + "text" + ] +} +``` + +Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts) + +### `terminal_signal` + +Send an allowed signal to the current foreground process group of a persistent terminal. + +```json +{ + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Terminal session id." + }, + "signal": { + "type": "string", + "description": "Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.", + "enum": [ + "SIGINT", + "SIGTERM", + "SIGKILL", + "SIGTSTP", + "SIGHUP" + ] + } + }, + "required": [ + "sessionId", + "signal" + ] +} +``` + +Source: [`packages/pty/tool-pty/src/index.ts`](../packages/pty/tool-pty/src/index.ts) + +The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. + ## `@deepseek-ai/dsh-tool-goal` ### `create_goal` @@ -715,7 +879,7 @@ Read a background task. Stream tasks return only output since the previous read; Source: [`packages/tasks/tool-tasks/src/index.ts`](../packages/tasks/tool-tasks/src/index.ts) -The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. +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` diff --git a/docs/web-styling.md b/docs/web-styling.md index d2b07ea2b5..0b6aeb55d5 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -1,6 +1,6 @@ # Web GUI 样式规范 -> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写);组件对账基准=`missions/tasks/20260721-1520-web-plugin-rfc/style-spec.md`。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace——这些已收编进 architecture.md §15。 +> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写),sheet 即权威、组件对账以它为准。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace。 > 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 334d3799af..eec63181df 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -86,7 +86,8 @@ export default tseslint.config( languageOptions: { parserOptions: { // Same shared project service as the src block: test files resolve - // through the root tsconfig (its include covers every tests/ tree). + // through the root solution to tsconfig.host.json (its include covers + // every host tests/ tree). projectService: true, tsconfigRootDir: import.meta.dirname, }, diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index c96cf0109c..d803da3d0e 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -29,7 +29,7 @@ Add to your Zed `settings.json` under `agent_servers`: } ``` -The editor sets each session's `cwd` to the project it opens, and bash uses that directory as its workdir. The current sandbox write boundary is nevertheless fixed when the server starts (`workspaceRoot: process.cwd()`), so launch the server from the workspace it should be allowed to modify; making that root session-scoped is deferred in the [sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). The filesystem tools ride the same sandbox policy through [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), so `read`/`write`/`edit` remain available regardless of plan state and confined to the same `workspaceRoot`. +The editor sets each session's `cwd` to the project it opens. That directory is both bash's default workdir and the session's primary `workspace-write` boundary: every bash or filesystem mutation carries one policy resolved from the calling session, so a single server process may serve concurrent projects. Projects outside the platform temporary areas do not grant either session writes into the other; `/tmp` and `os.tmpdir()` remain shared writable scratch roots under `workspace-write`, so projects placed there are not mutually isolated ([writable-root contract](../../packages/sandbox/sandbox/README.md)). The configured `workspaceRoot: process.cwd()` remains the fallback for calls without a session cwd. The filesystem tools ride the same policy through [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), so `read`/`write`/`edit` are available under every mode and confined to the same policy. ## Plan mode @@ -47,9 +47,9 @@ The default tree composes [`@deepseek-ai/dsh-sandbox-local`](../../packages/sand - **One session config option is live**: a capable client shows one `Permissions` select. `workspace-write` means workspace-confined bash plus `ask`; `danger-full-access` means unconfined bash plus `never`. Switching writes one `permission/preset` event through to the sandbox-mode and approval-policy events, and `session/load` reports the resumed value. - **Every approval is one-shot**: the choices are `Allow once` and `Reject`; a dismissal, rejection, missing editor, or unavailable runner fails closed. -- **The boundary spans bash and the filesystem tools, and is config-fixed today**: bash confines through the OS runner and the `read`/`write`/`edit` tools through an in-process path fence ([`dsh-fs-sandbox`](../../packages/fs/fs-sandbox/)), both keyed to the same `workspaceRoot` — which remains the server's launch directory (a per-session root is deferred). +- **The boundary spans bash and the filesystem tools per session**: bash confines through the OS runner and the `read`/`write`/`edit` tools through an in-process path fence ([`dsh-fs-sandbox`](../../packages/fs/fs-sandbox/)); both receive the calling session's cwd as `workspaceRoot`. -`tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. Most snapshots use that tree and start at `danger-full-access` so bash fixtures remain runner-independent; scenarios that call `read`, `write`, or `edit` use the fixed full-access fs overlay and a separate request-header pin. The permission-switching and escalation inputs select `workspace-write` before exercising the bash policy path. No fixture pins a real denial because kernel error text is backend-specific; real confinement remains covered by the sandbox packages' kernel e2e suites. +`tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. The agent-spine e2e independently boots one context with two home-directory project sessions and world-verifies concurrent own-root success plus sibling-root denial through both shipped tool families. The keyless `session-sandbox-root` ACP snapshot places its generated project under the user home while an overlay points the deployment fallback at `/tmp`; its successful `workspace-write` call proves the assembled app used the session cwd. Most snapshots start at `danger-full-access` so bash fixtures remain runner-independent. No fixture pins real runner denial text because its dialect is platform-specific. ## MVP limitations diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 4122867228..ffde5b0e3e 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -22,8 +22,8 @@ # workspace and asks before a wider retry. Snapshot runs select # danger-full-access so the established scenarios remain runner-independent; # DSH_PERMISSION_MODE provides the same explicit deployment/test override -# outside the snapshot harness. The sandbox mode + workspace root live on -# ctx.sandboxPolicy — the one home both enforcing families (bash, fs) read. +# outside the snapshot harness. The sandbox default + fallback root live on +# ctx.sandboxPolicy; agent calls resolve both families against the session cwd. - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/examples/acp-agent/packed-chunks.cordis.snapshot.yml b/examples/acp-agent/packed-chunks.cordis.snapshot.yml new file mode 100644 index 0000000000..11ca2bbe71 --- /dev/null +++ b/examples/acp-agent/packed-chunks.cordis.snapshot.yml @@ -0,0 +1,45 @@ +# Keyless replay counterpart of packed-chunks.cordis.yml. Patches do not +# compose across includes, so this applies the packChunks config and the +# DeepSeek-to-replay swap directly to `cordis.yml`. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: 'none' + packChunks: true + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/packed-chunks.cordis.yml b/examples/acp-agent/packed-chunks.cordis.yml new file mode 100644 index 0000000000..c44a4764e8 --- /dev/null +++ b/examples/acp-agent/packed-chunks.cordis.yml @@ -0,0 +1,23 @@ +# The packed-chunk-rows overlay: the base tree with the JSONL backend's +# `packChunks` switched on, so delta-chunk runs persist as packed storage rows. +# A config patch replaces the whole app config, so unchanged base fields are +# restated below. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + packChunks: true + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/pty-snapshot-backend.mjs b/examples/acp-agent/pty-snapshot-backend.mjs new file mode 100644 index 0000000000..8323c9fd5c --- /dev/null +++ b/examples/acp-agent/pty-snapshot-backend.mjs @@ -0,0 +1,64 @@ +/** Deterministic in-memory PTY backend for transcript snapshots. */ + +class SnapshotSession { + motd = 'dsh> ' + statusValue = { kind: 'running' } + scrollback = 'dsh> ' + + startSend(request) { + const viewport = `${request.text}\nPTY_OK\ndsh> ` + this.scrollback += viewport + const result = { + viewport, + waitReason: 'stdin_read', + sessionStatus: this.statusValue, + truncated: false, + } + let consumed = false + return { + done: Promise.resolve(result), + readOutput: () => { + if (consumed) return { delta: '', truncated: false } + consumed = true + return { delta: viewport, truncated: false } + }, + cancel: () => false, + } + } + + read(request) { + const lines = this.scrollback.split('\n') + const offset = request.offset ?? 0 + const count = request.count ?? 500 + const end = lines.length - offset + const start = Math.max(0, end - count) + const text = lines.slice(start, end).join('\n') + return { text, totalLines: lines.length, lineBegin: offset, lineEnd: offset + text.split('\n').length, truncated: false } + } + + signal() { + return Promise.resolve({ delivered: true, targetPgid: 1 }) + } + + status() { + return this.statusValue + } + + close() { + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + return Promise.resolve() + } +} + +/** Cordis plugin name. */ +export const name = 'pty-snapshot-backend' +/** Required PTY service. */ +export const inject = ['pty'] + +/** Register the deterministic snapshot backend. */ +export function apply(ctx) { + ctx.pty.registerBackend({ + type: 'shell', + spawn: () => Promise.resolve(new SnapshotSession()), + }) +} diff --git a/examples/acp-agent/pty.cordis.snapshot.yml b/examples/acp-agent/pty.cordis.snapshot.yml new file mode 100644 index 0000000000..9ef3ff6418 --- /dev/null +++ b/examples/acp-agent/pty.cordis.snapshot.yml @@ -0,0 +1,25 @@ +# Keyless replay counterpart to pty.cordis.yml. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: pty + name: '@deepseek-ai/dsh-pty' + - id: pty-snapshot-backend + name: './pty-snapshot-backend.mjs' + - id: tool-pty + name: '@deepseek-ai/dsh-tool-pty' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/pty.cordis.yml b/examples/acp-agent/pty.cordis.yml new file mode 100644 index 0000000000..019a2f797c --- /dev/null +++ b/examples/acp-agent/pty.cordis.yml @@ -0,0 +1,20 @@ +# Opt-in persistent PTY composition for the PTY snapshot scenario. The base +# deployment already owns the shared sandbox provider and policy. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: pty + name: '@deepseek-ai/dsh-pty' + - id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + pollIntervalMs: 10 + exactProbeAfterMs: 20 + idleSilenceMs: 250 + timeoutMs: 2000 + disposeGraceMs: 500 + - id: tool-pty + name: '@deepseek-ai/dsh-tool-pty' diff --git a/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml b/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml new file mode 100644 index 0000000000..f1261dc294 --- /dev/null +++ b/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml @@ -0,0 +1,50 @@ +# Keyless replay counterpart of session-sandbox-root.cordis.yml. Patches do not +# compose across nested includes, so the replay swap, the recorded model pin, +# and the deliberately distinct sandbox fallback are applied together to the +# live tree. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: /tmp + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/session-sandbox-root.cordis.yml b/examples/acp-agent/session-sandbox-root.cordis.yml new file mode 100644 index 0000000000..f27732fd68 --- /dev/null +++ b/examples/acp-agent/session-sandbox-root.cordis.yml @@ -0,0 +1,14 @@ +# Session-root sandbox snapshot overlay. The generated ACP session cwd lives +# under the user's home, while this deployment fallback deliberately points at +# /tmp. A workspace-write mutation can therefore succeed only when the calling +# session's cwd replaces the process-level fallback root. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" + workspaceRoot: /tmp diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b63017dac7..e35d47cea0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,6 +1,10 @@ import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' +import { homedir } from 'node:os' +import { expect, it } from 'vitest' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' +import { decodeStorageRecord } from '@deepseek-ai/dsh-session' /** * The acp-agent example's snapshot suite: the scenario table for @@ -30,8 +34,20 @@ 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 PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) +const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url)) +const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) +const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') +const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' + +function fixtureRecords(name: string): unknown[] { + return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8') + .trimEnd() + .split('\n') + .map(line => JSON.parse(line) as unknown) +} function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { @@ -71,6 +87,10 @@ const SCENARIOS: Scenario[] = [ // Its prompt and tool-schema sidecars pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, + // Authored from the real PACKED_CHUNKS_SOURCE recording under the same app + // composition. The contract below pins decoded equality and all three row + // kinds; replay additionally proves the assembled app re-packs identically. + { name: 'packed-chunks', hasModelTurn: true, recorded: false, configPath: PACKED_CHUNKS_CONFIG }, // The fs overlay only adds the spill stack (the sandboxed filesystem tools // live in the base tree), so these scenarios share the default header class. { @@ -80,6 +100,14 @@ const SCENARIOS: Scenario[] = [ configPath: FS_CONFIG, }, { name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG }, + { + name: 'pty-tools', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'pty', + configPath: PTY_CONFIG, + }, { name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, @@ -213,11 +241,37 @@ const SCENARIOS: Scenario[] = [ { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, { name: 'fs-escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, + // Unlike ordinary snapshots, this session cwd is outside the platform temp + // roots that workspace-write always grants. The overlay points the + // deployment fallback at /tmp, so a successful relative write proves the + // assembled app replaced that process-level fallback with SessionHeader.cwd. + { + name: 'session-sandbox-root', + hasModelTurn: true, + recorded: false, + overridden: true, + headerClass: 'sandbox', + configPath: SESSION_SANDBOX_ROOT_CONFIG, + workspaceParent: homedir(), + }, ] defineAcpSnapshotSuite({ agent: AGENT, - snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'), + snapshotsDir: SNAPSHOTS_DIR, scenarios: SCENARIOS, mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT), }) + +it('packed ACP fixture retains every chunk row kind without changing the logical session', () => { + const source = fixtureRecords(PACKED_CHUNKS_SOURCE) + const packed = fixtureRecords('packed-chunks') + const rowTypes = packed.flatMap((record) => { + if (record === null || typeof record !== 'object') return [] + const type = (record as { type?: unknown }).type + return type === 'text-chunks' || type === 'reasoning-chunks' || type === 'tool-call-chunks' ? [type] : [] + }) + + expect([...new Set(rowTypes)].sort()).toStrictEqual(['reasoning-chunks', 'text-chunks', 'tool-call-chunks']) + expect([packed[0], ...packed.slice(1).flatMap(record => decodeStorageRecord(record))]).toStrictEqual(source) +}) diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index ae53252743..1bf0a5ac16 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -25,13 +25,11 @@ import { cleanupAcpExampleTest } from './cleanup.ts' * model nor a sandbox runner is ever exercised. * * With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable - * platform runner): a scripted ACP client plays the human. The prompt asserts - * a prior denial (the organic denial→marker path lives on the sandbox e2e - * legs and unit tiers), the real model escalates with `sandbox_permissions` + - * `justification`, the bridge prompts THIS client over - * `session/request_permission`, the client answers `allow-once`, and the - * retried write must land ON DISK (world-verified) — under the granted mode, - * a temp-dir session cwd is writable either way. + * platform runner): a scripted ACP client plays the human. The subprocess + * starts read-only, its first real bash write is denied, the model retries with + * `sandbox_permissions` + `justification`, and the bridge prompts THIS client + * over `session/request_permission`. An approved workspace-write retry must + * then land ON DISK (world-verified). */ const AGENT: AgentUnderTest = { @@ -58,14 +56,21 @@ interface Spawned extends LaunchedAcpTestAgent { permissionRequests: RequestPermissionRequest[] } -/** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */ -function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { +/** Boot the example with an optional sandbox override; the scripted client answers every permission prompt with `answer`. */ +function launchExampleAcpAgent( + cwd: string, + answer: 'allow-once' | 'reject-once', + sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access', +): Spawned { const permissionRequests: RequestPermissionRequest[] = [] const launched = launchAcpTestAgent({ agent: AGENT, cwd, // A dummy key lets the adapter boot keylessly; live tests carry the real key. - env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + env: { + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', + DSH_PERMISSION_MODE: sandboxMode, + }, requestPermission(params) { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) @@ -78,6 +83,17 @@ function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once' return Object.assign(launched, { permissionRequests }) } +function escalationPrompt(path: string, content: string): string { + return `Create ${path} containing exactly ${JSON.stringify(content)} using bash, not filesystem tools. ` + + 'First try the command without sandbox_permissions. If the sandbox denies it, retry that exact command once ' + + 'with sandbox_permissions set to workspace-write and a one-sentence justification.' +} + +function includesReadOnlyDenial(updates: LaunchedAcpTestAgent['updates']): boolean { + return updates.some(update => update.sessionUpdate === 'tool_call_update' + && JSON.stringify(update.content).includes('[sandbox: file access denied under read-only mode]')) +} + let spawned: Spawned | undefined let workdir: string | undefined @@ -137,17 +153,20 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox composition e2e: the live approval loop', () => { it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = launchExampleAcpAgent(workdir, 'allow-once') - const { client, permissionRequests } = spawned + spawned = launchExampleAcpAgent(workdir, 'allow-once', 'read-only') + const { client, permissionRequests, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) const res = await client.prompt({ sessionId, - prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/escalated.txt. Create it now containing exactly "ACP_ESCALATION_OK": ` - + 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification, then stop.' }], + prompt: [{ + type: 'text', + text: `${escalationPrompt(join(workdir, 'escalated.txt'), 'ACP_ESCALATION_OK')} Then stop.`, + }], }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) + expect(includesReadOnlyDenial(updates)).toBe(true) // The WORLD: the approved escalated retry landed the write. const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8') @@ -166,17 +185,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co it('a rejected escalation stays denied: no write lands, the turn still ends', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = launchExampleAcpAgent(workdir, 'reject-once') - const { client, permissionRequests } = spawned + spawned = launchExampleAcpAgent(workdir, 'reject-once', 'read-only') + const { client, permissionRequests, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) const res = await client.prompt({ sessionId, - prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/refused.txt. Create it now containing "NO": ` - + 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification. If that is rejected, stop and say so.' }], + prompt: [{ + type: 'text', + text: `${escalationPrompt(join(workdir, 'refused.txt'), 'NO')} If approval is rejected, stop and say so.`, + }], }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) + expect(includesReadOnlyDenial(updates)).toBe(true) // The WORLD: rejected means the file never appeared. await expect(readFile(join(workdir, 'refused.txt'), 'utf8')).rejects.toThrow() diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl index 1b3c8688ed..747412d4f0 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Create a durable two-round goal","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.jsonl b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.jsonl new file mode 100644 index 0000000000..16d72490a8 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"semantic-checkpoint-replay","createdAt":1,"delegationDepth":0} diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.override.json b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.override.json new file mode 100644 index 0000000000..192212820e --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.override.json @@ -0,0 +1,11 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "I will verify the external state before deciding whether to retry the side-effecting operation." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "I will verify the external state before deciding whether to retry the side-effecting operation." } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl new file mode 100644 index 0000000000..fa003415b7 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl @@ -0,0 +1,21 @@ +{"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":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"assistant/message","seq":3,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"}},"surfaceOp":"append"} +{"type":"tool/call","seq":4,"time":0,"data":{"turn":1,"step":1,"callId":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}} +{"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"callId":"unknown-outcome-call","content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}],"isError":true,"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]} +{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}} +{"type":"turn/start","seq":8,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":10,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":11,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"}},"sourceEventSeqs":[13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":19,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/stdout.expected.jsonl b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/stdout.expected.jsonl new file mode 100644 index 0000000000..3075390cf5 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/stdout.expected.jsonl @@ -0,0 +1,9 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Perform one side-effecting remote mutation."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"unknown-outcome-call","title":"write_remote","kind":"other","status":"in_progress","rawInput":{"value":1}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"unknown-outcome-call","status":"failed","content":[{"type":"content","content":{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} +{"jsonrpc":"2.0","id":2,"result":{"modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Perform one side-effecting remote mutati","updatedAt":"{{updatedAt}}"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/semantic-checkpoint.snapshot.ts b/examples/acp-agent/tests/semantic-checkpoint.snapshot.ts new file mode 100644 index 0000000000..e43d2ca4d0 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint.snapshot.ts @@ -0,0 +1,129 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { + launchAcpTestAgent, + normalizeSessionLog, + normalizeStdout, + scrubRequestHeaders, + type AgentUnderTest, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { describe, expect, it } from 'vitest' + +const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'semantic-checkpoint-snapshots/tool-outcome-unknown') +const replayFixture = join(fixtureDir, 'replay.jsonl') +const replayOverride = join(fixtureDir, 'replay.override.json') +const stdoutExpected = join(fixtureDir, 'stdout.expected.jsonl') +const sessionExpected = join(fixtureDir, 'session.expected.jsonl') +const sessionId = SessionId('semantic-checkpoint-unknown-outcome') +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' + +const agent: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), +} + +async function seedInterruptedSession(root: string, cwd: string): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const meta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: sessionId, + createdAt: 1, + cwd, + delegationDepth: 0, + } + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: 11, data: { content: [{ type: 'text', text: 'Perform one side-effecting remote mutation.' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: 12, data: { turn: 1, step: 1 } }, + { + type: 'assistant/message', + seq: 3, + time: 13, + data: { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: CallId('unknown-outcome-call'), name: 'write_remote', arguments: '{"value":1}' }], + provenance: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + }, + surfaceOp: 'append', + }, + { + type: 'tool/call', + seq: 4, + time: 14, + data: { + turn: 1, + step: 1, + callId: CallId('unknown-outcome-call'), + name: 'write_remote', + arguments: '{"value":1}', + }, + }, + ] + try { + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(sessionId, events) + const location = ctx.sessionPersistence.locate(meta) + if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') + return location.path + } finally { + await ctx.fiber.dispose() + } +} + +describe('semantic checkpoint recovery snapshot', () => { + it('loads an unknown tool outcome and carries retry-risk guidance into the next model turn', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-semantic-snapshot-cwd-')) + const sessionsRoot = await mkdtemp(join(tmpdir(), 'dsh-semantic-snapshot-sessions-')) + let launched: ReturnType | undefined + try { + const sessionPath = await seedInterruptedSession(sessionsRoot, cwd) + launched = launchAcpTestAgent({ + agent, + cwd, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: replayFixture, + DSH_SNAPSHOT_OVERRIDE: replayOverride, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await launched.client.loadSession({ sessionId, cwd, mcpServers: [] }) + await launched.client.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'Continue safely from the interrupted operation.' }], + }) + await launched.close() + + const normalization: NormalizeContext = { sessionIds: [sessionId], cwd } + const stdout = normalizeStdout(launched.rawStdout(), normalization) + const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization)) + if (refreshing) { + await writeFile(stdoutExpected, stdout) + await writeFile(sessionExpected, session) + } + expect(stdout).toBe(await readFile(stdoutExpected, 'utf8')) + expect(session).toBe(await readFile(sessionExpected, 'utf8')) + expect(session).toContain('TOOL_OUTCOME_UNKNOWN') + expect(session).toContain('Do not retry blindly.') + } finally { + await launched?.close('SIGKILL').catch(() => undefined) + await Promise.all([ + rm(cwd, { recursive: true, force: true }), + rm(sessionsRoot, { recursive: true, force: true }), + ]) + } + }) +}) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl index c53a311f32..94914e7eda 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Run this advanced flow exactly","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl index 7323919b28..ca3eedb9cd 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl index 7184499d06..99d2f0bd5b 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Call the run_code tool (NOT","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json index 0f40e9d8b6..7024820966 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json @@ -6,7 +6,9 @@ "op": "promptAndCancel", "text": "Run two shell commands: wait for cancellation, then write skipped.txt.", "afterUpdate": "tool_call", + "waitForFile": { "path": "started.txt" }, "waitForToolCallUpdate": "call_skipped" - } + }, + { "op": "waitForTurnEnd" } ] } diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json index c0aa7730d7..ec47a5cd1d 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json @@ -3,8 +3,8 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } }, + { "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } }, { "type": "block-start", "index": 1, "blockType": "tool-call" }, { "type": "tool-call-delta", "index": 1, "id": "call_skipped", "name": "bash", "argumentsDelta": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" }, { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_skipped", "name": "bash", "arguments": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" } }, diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index 25a6c1aabf..16d218f778 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -5,15 +5,15 @@ {"type":"step/start","seq":3,"time":1784437195076,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1784437195076,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} {"type":"assistant/chunk","seq":8,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skipped","name":"bash","argumentsDelta":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}} {"type":"assistant/chunk","seq":10,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":10}}}} {"type":"assistant/chunk","seq":12,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} +{"type":"assistant/message","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} {"type":"tool/result","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} {"type":"tool/result","seq":17,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[16],"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index 63d5bc0927..a2b46185d3 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -1,8 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Run two shell commands: wait","updatedAt":"{{updatedAt}}"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"printf skipped > skipped.txt","kind":"execute","status":"in_progress","rawInput":"printf skipped > skipped.txt","content":[{"type":"content","content":{"type":"text","text":"Write skipped marker"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/input.json b/examples/acp-agent/tests/snapshots/cancel/input.json index 0bc989ed10..a2e2fdc5f0 100644 --- a/examples/acp-agent/tests/snapshots/cancel/input.json +++ b/examples/acp-agent/tests/snapshots/cancel/input.json @@ -2,6 +2,7 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "promptAndCancel", "text": "Start a long task; this turn will be cancelled mid-stream." } + { "op": "promptAndCancel", "text": "Start a long task; this turn will be cancelled mid-stream." }, + { "op": "waitForTurnEnd" } ] } diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index adc8493618..9cf7d7d4c4 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Start a long task; this","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl index 65c5350091..5e366ec1a3 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Using ONE run_code program: call","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl index 579f2a19cc..5bdacf3b5c 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Using ONE run_code program, call","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl index f8d8228f06..54063e3477 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 66c1651fe3..bd13d3b146 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index b58a707bce..6a78b05a65 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -1,9 +1,9 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl index 1019c9057c..b07d78c68b 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"This prompt triggers a recorded","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl index a2ad0a6116..445da921d3 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl index 3f7eff9b32..a634adb5db 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl index 5eb3211ffb..6a9e22ca4f 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"First use the read tool","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl index 12b5b11c54..1870ad0e74 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl index 8263434a3e..2392596350 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Do NOT use the read","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl index 851252a7f3..9fb6651a32 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the read tool (NOT","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl index 5a504a69d1..d07b3280e6 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the read tool (NOT","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl index fc1182e7d1..86458277ad 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl index 849ff51af1..002f1b0da0 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"First use the read tool","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl index 475f437a44..955deb74df 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the write tool (NOT","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl index 86ef26050b..12146e50c0 100644 --- a/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"No goal is currently set.\nUsage: /goal [|clear|edit |pause|resume]"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl index 3320197421..f80709f106 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl @@ -1,3 +1,3 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl index db89f8db97..08adb8f230 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Call the bash tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl index 827dc55637..e9284e2893 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl index 2a3d8294b9..ad13ccf938 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl index bd569e474c..54e62a3a6d 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl index ad584a4ec3..dc5fb9671c 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl index 8186d5bbb2..b5fa736957 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"What is my favorite color?","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl index 5d67d704e4..0ec240ce55 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Reply with the single word","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl index 5806fbf699..edf46eb31e 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Call the bash tool exactly","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl index f79581043a..9656372c47 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl index 6ab55160f8..7a8e32a434 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl index ad584a4ec3..dc5fb9671c 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl index ee8725ddaa..bd8177b30a 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"What is my favorite color?","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl index a7642218a4..d2fb6a6df9 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Reply with the single word","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl index 01f7b9d190..c58fa5004c 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the lsp tool exactly","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl index 265df8545c..d44158e357 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Without using tools, reply with","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/modes-advertise/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/modes-advertise/stdout.expected.jsonl index c43a19bfd3..86aec432ae 100644 --- a/examples/acp-agent/tests/snapshots/modes-advertise/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/modes-advertise/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"current_mode_update","currentModeId":"plan"}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl index 2151071ed4..9d4ccf1046 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Reply with exactly the word:","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/packed-chunks/input.json b/examples/acp-agent/tests/snapshots/packed-chunks/input.json new file mode 100644 index 0000000000..3d44990f9b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/packed-chunks/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl new file mode 100644 index 0000000000..9928cbe82d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl @@ -0,0 +1,32 @@ +{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"/tmp/acp-snap-cwd-wDnkVo","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1783352165198,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1783352165199,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1783352165899,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1783352165899,"data":{"turn":1,"step":1,"index":0,"dt":[149,27,0,0,1,0,0,28,0,1,0,0,28,0,27,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}} +{"type":"assistant/chunk","seq":23,"time":1783352166218,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":24,"time0":1783352166218,"data":{"turn":1,"step":1,"index":1,"dt":[32,0,0,28,1,0,0,29,0,1,0,27,1,28,0,0,0,29,0,28,0,0,0,31],"id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}} +{"type":"assistant/chunk","seq":49,"time":1783352166512,"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 report the result verbatim."}}}} +{"type":"assistant/chunk","seq":50,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":51,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":52,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":53,"time":1783352166514,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"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],"surfaceOp":"append"} +{"type":"tool/call","seq":54,"time":1783352166514,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":55,"time":1783352166515,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":56,"time":1783352166528,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":12.367074000000684}} +{"type":"tool/result","seq":57,"time":1783352166528,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"step/end","seq":58,"time":1783352166529,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":59,"time":1783352166529,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":60,"time":1783352167307,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":61,"time0":1783352167308,"data":{"turn":1,"step":2,"index":0,"dt":[132,29,0,0,0,0,1,27,0,28,1,0,31,0,25,0,29,1,1,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy","."," I"," need"," to"," report"," this"," error"," verb","atim"," back"," to"," the"," user","."]}} +{"type":"assistant/chunk","seq":82,"time":1783352167613,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":83,"time0":1783352167613,"data":{"turn":1,"step":2,"index":1,"dt":[30,29,29,0,1,28,0,1,0,0,0,26,1,0,0,0,28,0,31,0,25,30,1,0,27,1,0,31,1],"texts":["The"," tool"," returned",":\n\n",">"," Error",":"," bash"," is"," disabled"," by"," policy"," in"," this"," session","\n\n","I"," cannot"," run"," the"," command"," because"," the"," bash"," tool"," is"," disabled"," by"," policy","."]}} +{"type":"assistant/chunk","seq":113,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."}}}} +{"type":"assistant/chunk","seq":114,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}} +{"type":"assistant/chunk","seq":115,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":116,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":117,"time":1783352167934,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."},{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116],"surfaceOp":"append"} +{"type":"step/end","seq":118,"time":1783352167934,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":119,"time":1783352167934,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/packed-chunks/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/packed-chunks/stdout.expected.jsonl new file mode 100644 index 0000000000..54e62a3a6d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/packed-chunks/stdout.expected.jsonl @@ -0,0 +1,75 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" simple"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: bash is disabled by policy in this session\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" disabled"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" error"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":">"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" Error"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" disabled"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" session"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" cannot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" because"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" disabled"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/packed-chunks/workspace/hooks.json b/examples/acp-agent/tests/snapshots/packed-chunks/workspace/hooks.json new file mode 100644 index 0000000000..f509c811c3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/packed-chunks/workspace/hooks.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "bash", + "hooks": [ + { "type": "command", "command": "echo 'bash is disabled by policy in this session' >&2; exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl index 600458b67a..a3595f8f4f 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the read tool twice","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl index fd78fe6dc2..a53224e202 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/plan-mode-reject/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/plan-mode-reject/stdout.expected.jsonl index 02878c0201..1fade38776 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode-reject/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/plan-mode-reject/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"current_mode_update","currentModeId":"plan"}}} diff --git a/examples/acp-agent/tests/snapshots/plan-mode/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/plan-mode/stdout.expected.jsonl index 2fa56587ef..9d67e0f2eb 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/plan-mode/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"current_mode_update","currentModeId":"plan"}}} diff --git a/examples/acp-agent/tests/snapshots/pty-tools/input.json b/examples/acp-agent/tests/snapshots/pty-tools/input.json new file mode 100644 index 0000000000..abb800b56b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pty-tools/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize", "terminalOutput": true }, + { "op": "newSession" }, + { "op": "prompt", "text": "Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl new file mode 100644 index 0000000000..f3157d811b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl @@ -0,0 +1,74 @@ +{"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":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","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-pro"},"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":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} +{"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":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"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":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"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":"pty-send","name":"terminal_send","argumentsDelta":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} +{"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":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"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":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} +{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":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":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-read","name":"terminal_read","argumentsDelta":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} +{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"terminal_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}} +{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} +{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} +{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"terminal_close","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} +{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"terminal_list","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"tool/call","seq":61,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} +{"type":"tool/result","seq":62,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false},"sourceEventSeqs":[61],"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}} +{"type":"step/start","seq":64,"time":0,"data":{"turn":1,"step":7}} +{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":70,"time":0,"data":{"turn":1,"step":7,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"} +{"type":"step/end","seq":71,"time":0,"data":{"turn":1,"step":7}} +{"type":"turn/end","seq":72,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl new file mode 100644 index 0000000000..0158e55d12 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl @@ -0,0 +1,18 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-pro\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Exercise the six PTY tools","updatedAt":"{{updatedAt}}"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-spawn","title":"Open terminal main","kind":"execute","status":"in_progress"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-spawn","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-send","title":"printf 'PTY_OK\\n'","kind":"execute","status":"in_progress","rawInput":"printf 'PTY_OK\\n'","content":[{"type":"content","content":{"type":"text","text":"Terminal pty-1"}},{"type":"terminal","terminalId":"pty-send"}],"_meta":{"terminal_info":{"terminal_id":"pty-send","cwd":"{{cwd}}"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-send","status":"completed","_meta":{"terminal_output":{"terminal_id":"pty-send","data":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-read","title":"Read terminal pty-1","kind":"read","status":"in_progress","rawInput":{"sessionId":"pty-1","offset":0,"count":20}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-signal","title":"Signal terminal pty-missing","kind":"execute","status":"in_progress","rawInput":{"sessionId":"pty-missing","signal":"SIGINT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-signal","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown PTY session pty-missing"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-kill","title":"Close terminal pty-1","kind":"delete","status":"in_progress"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-kill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"closed terminal session pty-1"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-list","title":"List terminal sessions","kind":"read","status":"in_progress"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-list","status":"completed","content":[{"type":"content","content":{"type":"text","text":"(no terminal sessions)"}}]}}} +{"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/pty-tools/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md new file mode 100644 index 0000000000..df065a83cb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pty-tools/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-pro 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. + +Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited. + +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 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/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json new file mode 100644 index 0000000000..d466996ed1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -0,0 +1,675 @@ +{ + "initial": [ + { + "name": "ask_user_question", + "description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.", + "parameters": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "Questions to ask the user before continuing.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id for this question; echoed in the answer." + }, + "question": { + "type": "string", + "description": "The specific question to ask the user." + }, + "header": { + "type": "string", + "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." + }, + "options": { + "type": "array", + "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Short user-facing option label." + }, + "description": { + "type": "string", + "description": "One sentence explaining the tradeoff or impact." + } + }, + "required": [ + "label" + ] + } + }, + "multi_select": { + "type": "boolean", + "description": "Whether the user may select more than one option. Defaults to false." + } + }, + "required": [ + "id", + "question" + ] + } + } + }, + "required": [ + "questions" + ] + } + }, + { + "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": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "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": "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": "terminal_close", + "description": "Close one persistent terminal and wait until its captured owned process tree is gone.", + "parameters": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Terminal session id." + } + }, + "required": [ + "sessionId" + ] + } + }, + { + "name": "terminal_list", + "description": "List persistent terminal sessions owned by the current agent.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "terminal_open", + "description": "Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Registered terminal backend type, usually \"shell\"." + }, + "name": { + "type": "string", + "description": "Optional owner-local display name such as \"main\" or \"gdb\"." + }, + "cwd": { + "type": "string", + "description": "Initial working directory. Defaults to the deployment workspace root." + } + }, + "required": [ + "type" + ] + } + }, + { + "name": "terminal_read", + "description": "Read a bounded page of retained output from a persistent terminal without sending input.", + "parameters": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Terminal session id." + }, + "offset": { + "type": "number", + "description": "Newest-relative line offset (default 0)." + }, + "count": { + "type": "number", + "description": "Requested line count (default 500; backend caps apply)." + } + }, + "required": [ + "sessionId" + ] + } + }, + { + "name": "terminal_send", + "description": "Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.", + "parameters": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Terminal session id returned by terminal_open or terminal_list." + }, + "text": { + "type": "string", + "description": "UTF-8 text to write to the terminal." + }, + "submit": { + "type": "boolean", + "description": "Submit Enter after text (default true). Set false for control characters or incomplete REPL input." + }, + "run_in_background": { + "type": "boolean", + "description": "Return a task id immediately; collect with task_output or stop with task_kill." + } + }, + "required": [ + "sessionId", + "text" + ] + } + }, + { + "name": "terminal_signal", + "description": "Send an allowed signal to the current foreground process group of a persistent terminal.", + "parameters": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Terminal session id." + }, + "signal": { + "type": "string", + "description": "Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.", + "enum": [ + "SIGINT", + "SIGTERM", + "SIGKILL", + "SIGTSTP", + "SIGHUP" + ] + } + }, + "required": [ + "sessionId", + "signal" + ] + } + }, + { + "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", + "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 — no oneOf/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).", + "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", + "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\": [...]})." + } + }, + "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/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl index 4b864fe7f3..b715cabc47 100644 --- a/examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl @@ -1,2 +1,2 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"Invalid params: additionalDirectories is not supported in this MVP"}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl index 1b1155bfb8..a9ed1f9e72 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Write the todo list 'watch","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/input.json b/examples/acp-agent/tests/snapshots/session-sandbox-root/input.json new file mode 100644 index 0000000000..9cef40f51d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "setConfigOption", "configId": "permission", "value": "workspace-write" }, + { "op": "prompt", "text": "Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/replay.override.json b/examples/acp-agent/tests/snapshots/session-sandbox-root/replay.override.json new file mode 100644 index 0000000000..511613b441 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/replay.override.json @@ -0,0 +1,22 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_session_root", "name": "write", "argumentsDelta": "{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_session_root", "name": "write", "arguments": "{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "DONE" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "DONE" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl b/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl new file mode 100644 index 0000000000..1abfd3ba7d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl @@ -0,0 +1,27 @@ +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0,"cwd":"/Users/cty/acp-snap-cwd-MABAjO","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1784567324138,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"permission/preset","seq":1,"time":1784567324138,"data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","seq":2,"time":1784567324138,"data":{"mode":"workspace-write"}} +{"type":"approval/policy","seq":3,"time":1784567324138,"data":{"policy":"ask"}} +{"type":"user/message","seq":4,"time":1784567324138,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1784567324138,"data":{"title":"Use the write tool (NOT","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":6,"time":1784567324142,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":7,"time":1784567324142,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":8,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_root","name":"write","argumentsDelta":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}} +{"type":"assistant/chunk","seq":10,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":12,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1784567324144,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1784567324145,"data":{"turn":1,"step":1,"callId":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} +{"type":"tool/result","seq":15,"time":1784567324155,"data":{"turn":1,"step":1,"callId":"call_session_root","content":[{"type":"text","text":"/Users/cty/acp-snap-cwd-MABAjO/session-root.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1784567324157,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1784567324157,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1784567324157,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1784567324157,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":20,"time":1784567324158,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":21,"time":1784567324158,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":22,"time":1784567324158,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":23,"time":1784567324158,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"step/end","seq":24,"time":1784567324158,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":25,"time":1784567324158,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl new file mode 100644 index 0000000000..cd845049a6 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl @@ -0,0 +1,9 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the write tool (NOT","updatedAt":"{{updatedAt}}"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_session_root","title":"Write session-root.txt","kind":"edit","status":"in_progress","locations":[{"path":"session-root.txt"}],"content":[{"type":"diff","path":"session-root.txt","oldText":null,"newText":"session root"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_root","status":"completed","content":[{"type":"diff","path":"session-root.txt","oldText":null,"newText":"session root"}],"title":"Write session-root.txt"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl index 1c05855181..44e245ef46 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Load the snapshot-skill skill with","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl index 44d7c5d000..3483f9490b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Delegate through two child generations.","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl index 01050bbb21..d1184f6b12 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Remember this fact for later:","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl index e413c72b36..571f65d806 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Remember this fact for later:","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl index 1636ccc9f4..fa42c3b416 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the subagent tool TWICE,","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl index 70633ca4b5..57176c4439 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the subagent tool exactly","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl index 3ba70963d2..e83383eec3 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Reply with exactly the word:","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl index 87c9de38fc..73589a0aae 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the todo_write tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl index 7a76747755..f730b1a24e 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl index 6769815989..05f86e858a 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the workflow tool exactly","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl index 5ac3496843..39d2d8c5b0 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Read nested/task.txt with the read","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl index 2d9b6c418c..a5deb50252 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"A file named greeting.txt in","updatedAt":"{{updatedAt}}"}}} diff --git a/examples/headless-agent/pty.cordis.snapshot.yml b/examples/headless-agent/pty.cordis.snapshot.yml new file mode 100644 index 0000000000..f7fcea389a --- /dev/null +++ b/examples/headless-agent/pty.cordis.snapshot.yml @@ -0,0 +1,18 @@ +# Keyless opt-in PTY composition for the headless stream-json snapshot. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: pty + name: '@deepseek-ai/dsh-pty' + - id: pty-snapshot-backend + name: '../acp-agent/pty-snapshot-backend.mjs' + - id: tool-pty + name: '@deepseek-ai/dsh-tool-pty' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index 6cfa31a750..e1edc1dadd 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -10,6 +10,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as SessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' @@ -70,7 +71,10 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio // Durable JSONL persistence is opt-in: only the resume e2e needs it, and the // other suites stay file-free. Loaded last so a resume's deferred // `ctx.inject(['sessionPersistence'])` resolves once this is present. - if (options.persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) + if (options.persistenceRoot !== undefined) { + await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) + await ctx.plugin(SessionCheckpointPolicy) + } return ctx } diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index af9739b613..1626489bf5 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -18,6 +18,10 @@ const advancedScenarioDir = join(snapshotsDir, 'advanced-toolchain') const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl') const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl') const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) +const ptyScenarioDir = join(snapshotsDir, 'pty-tools') +const ptySessionFixture = join(ptyScenarioDir, 'session.jsonl') +const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl') +const ptyConfigPath = fileURLToPath(new URL('../pty.cordis.snapshot.yml', import.meta.url)) const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') @@ -318,4 +322,53 @@ describe('headless stream-json snapshots', () => { if (refreshing) await writeFile(streamExpected, normalized) expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('replays persistent PTY tools through the one-shot app', async () => { + const input = JSON.parse(await readFile(join(ptyScenarioDir, 'input.json'), 'utf8')) as { + steps?: { op?: unknown; text?: unknown }[] + } + const prompt = input.steps?.find(step => step.op === 'prompt')?.text + if (typeof prompt !== 'string') throw new Error('pty-tools input has no prompt step') + let expectedSession = await readFile(ptySessionFixture, 'utf8') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'headless persistent PTY snapshot', + tempDirPrefix: 'headless-snapshot-pty-', + binScript, + configPath: ptyConfigPath, + binArgs: ['--config', ptyConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: ptySessionFixture, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const actual = logs[0] + if (actual === undefined) throw new Error('headless PTY snapshot did not persist its session') + if (refreshing) { + const harvested: HarvestedLog = { + id: String(actual.header.id), + createdAt: Number(actual.header.createdAt), + content: actual.content, + } + const replacements = refreshFixtureReplacements([harvested], [expectedSession]) + expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements) + await writeFile(ptySessionFixture, expectedSession) + } + const actualContext = contextFromLogs([actual.content]) + const expectedContext = contextFromLogs([expectedSession]) + expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) + .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext))) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(ptyStreamExpected, normalized) + expect(normalized).toBe(await readFile(ptyStreamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/headless-agent/tests/snapshots/pty-tools/input.json b/examples/headless-agent/tests/snapshots/pty-tools/input.json new file mode 100644 index 0000000000..abb800b56b --- /dev/null +++ b/examples/headless-agent/tests/snapshots/pty-tools/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize", "terminalOutput": true }, + { "op": "newSession" }, + { "op": "prompt", "text": "Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE." } + ] +} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl new file mode 100644 index 0000000000..bad2f0353d --- /dev/null +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -0,0 +1,74 @@ +{"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":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","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":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack 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.\n\nUse 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.\n\nUse 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.","tools":[{"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`.","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."}},"required":["command","description"]}},{"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."}},"required":["file_path","old_string","new_string"]}},{"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":"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":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"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","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":"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 — no oneOf/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).","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","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\": [...]})."}},"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."}},"required":["file_path","content"]}}]},"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":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} +{"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":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"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":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"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":"pty-send","name":"terminal_send","argumentsDelta":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} +{"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":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"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":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} +{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":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":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-read","name":"terminal_read","argumentsDelta":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} +{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"terminal_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}} +{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} +{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} +{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"terminal_close","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} +{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"terminal_list","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"tool/call","seq":61,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} +{"type":"tool/result","seq":62,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false},"sourceEventSeqs":[61],"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}} +{"type":"step/start","seq":64,"time":0,"data":{"turn":1,"step":7}} +{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":70,"time":0,"data":{"turn":1,"step":7,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"} +{"type":"step/end","seq":71,"time":0,"data":{"turn":1,"step":7}} +{"type":"turn/end","seq":72,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl new file mode 100644 index 0000000000..b4db490cb2 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl @@ -0,0 +1,74 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-send","name":"terminal_send","argumentsDelta":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","content":[{"type":"text","text":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[wait: stdin_read]\n[session: running]"}],"isError":false,"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-read","name":"terminal_read","argumentsDelta":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"terminal_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true},"sourceEventSeqs":[41],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"terminal_close","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"terminal_list","argumentsDelta":"{}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":61,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":62,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false},"sourceEventSeqs":[61],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":64,"time":0,"data":{"turn":1,"step":7}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":70,"time":0,"data":{"turn":1,"step":7,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":71,"time":0,"data":{"turn":1,"step":7}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":72,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"DONE","reason":{"kind":"completed"},"usage":{"inputTokens":70,"outputTokens":33}} diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 2ff464de7b..f0eb7ea6d8 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -39,6 +39,9 @@ config: root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' + - id: subagent name: '@deepseek-ai/dsh-subagent' diff --git a/examples/package.json b/examples/package.json index 1ceeb0930a..db8cc142eb 100644 --- a/examples/package.json +++ b/examples/package.json @@ -31,10 +31,13 @@ "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", + "@deepseek-ai/dsh-pty": "workspace:*", + "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-tui-demo": "workspace:*", @@ -50,6 +53,7 @@ "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index c5d6fa36f3..624041854e 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -99,7 +99,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/plan exercise the TUI\r' }, - { waitFor: 'How should the scripted run proceed?', send: '\r' }, + // The question text first appears in the streamed tool-call card. Wait + // for the dialog's input legend so Enter cannot arrive before it owns + // terminal input when pre-dispatch policy yields. + { waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, // Session title: the first user message drives the first-message-llm // provider's tool-less title call; the scripted adapter answers it, the diff --git a/knip.json b/knip.json index baab7e1d7b..3a7a443718 100644 --- a/knip.json +++ b/knip.json @@ -88,8 +88,7 @@ }, "packages/client/runtime": { "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.spec.ts" ], "project": [ "src/**/*.ts", @@ -106,6 +105,16 @@ "tests/**/*.tsx" ] }, + "packages/client/web-react": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/ui-layout": { "entry": [ "tests/**/*.spec.ts", @@ -116,9 +125,6 @@ "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx" - ], - "ignoreDependencies": [ - "@deepseek-ai/dsh-client-ui-slots" ] }, "website": { @@ -317,6 +323,10 @@ "tests/**/*.ts" ] }, + "packages/session-persistence/session-checkpoint-policy": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/util/paths": { "entry": [ "tests/**/*.spec.ts" @@ -376,6 +386,10 @@ "tests/**/*.ts" ] }, + "packages/examples/agent-spine-demo": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/ui/jsonrpc": { "entry": [ "tests/**/*.spec.ts", diff --git a/lefthook.yml b/lefthook.yml index 49a1499976..cf2e6bb11d 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -20,4 +20,4 @@ pre-commit: pre-push: jobs: - name: typecheck - run: node_modules/.bin/tsc -b tsconfig.json --pretty false + run: node_modules/.bin/tsc -b --pretty false diff --git a/package.json b/package.json index cab2592fea..38db3a2ac7 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,10 @@ "website" ], "scripts": { - "build": "tsc -b tsconfig.build.json && tsdown", + "build": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo", - "typecheck": "tsc -b tsconfig.json tsconfig.client.json", + "typecheck": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", @@ -29,6 +29,7 @@ "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "test:web": "npm run build:web && vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", + "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 4fc767173f..bb7fdfa839 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -19,6 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md Naming notes: +- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. diff --git a/packages/README.md b/packages/README.md index 932d4c1562..b138ea0b65 100644 --- a/packages/README.md +++ b/packages/README.md @@ -12,6 +12,7 @@ Packages live at `packages///`; groups are containers, while names r | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | +| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface | | [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface | diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 6428cd7ac8..2c25701fb9 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -109,10 +109,10 @@ export class LocalBashExecutor extends BashExecutor { ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, - // Carry a sandbox-mode override through verbatim: this executor never + // Carry a sandbox policy through verbatim: this executor never // confines, so the field is inert here (the seam contract) — a // sandboxing subclass overrides resolve() to stamp its default instead. - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 97419b45d4..93e0c9e6f2 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -16,7 +16,7 @@ Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). - **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting. -- **Deployment default, per-call policy.** The DEFAULT mode + workspace root are owned by [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (one home both enforcing families read), not this executor's config; `resolve()` stamps the default onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt. +- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt. - **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. - Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/). @@ -29,12 +29,12 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego name: '@deepseek-ai/dsh-sandbox-policy' config: mode: read-only - workspaceRoot: !!js process.cwd() + workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd - id: bash name: '@deepseek-ai/dsh-bash-sandbox' ``` -The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo. +The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent). The agent-spine e2e additionally drives two concurrent sessions in one Cordis context and proves each real bash tool call can write only its own project. See [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo. ## Model Experience diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 67b850916c..b889692f3c 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -3,14 +3,15 @@ * `ctx.sandbox`, inherits local process mechanics, and reports the selected * mode, enforcement, and denial facts. Runner failure means the command never * ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background - * processes carry `runnerFailed`. The tool owns approval and passes per-call modes. + * processes carry `runnerFailed`. The tool owns approval and passes a complete + * per-call policy. * @module @deepseek-ai/dsh-bash-sandbox */ import { Context } from 'cordis' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' @@ -18,21 +19,19 @@ import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } f /** * Plugin config: the local executor's knobs, verbatim. The sandbox policy — - * the default mode and the `workspace-write` boundary root — is NOT here: it - * lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one - * home both enforcing families read, so bash and fs can never confine to - * different roots. The runner choice is likewise the `ctx.sandbox` provider's - * config, not this executor's. + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for both enforcing families. The runner + * choice is likewise the `ctx.sandbox` provider's config, not this executor's. */ export type Config = LocalConfig /** * Registers as `ctx.bash` in place of the local executor and requires a * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is - * unchanged. The policy default (mode + workspace root) is the fallback, - * while a session override or approved one-shot escalation may select each - * call's mode. The prompt does not state the standing mode; `result.sandbox` - * reports the mode and enforcement actually used. + * unchanged. Tool calls pass the calling session's resolved policy; direct + * calls fall back to deployment policy. The prompt does not state the standing + * mode; `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { static inject = ['sandbox', 'sandboxPolicy'] @@ -42,7 +41,6 @@ export class SandboxBashExecutor extends LocalBashExecutor { // verbatim (the config catalog walks the inherited static). private readonly mode: SandboxMode - private readonly workspaceRoot: string /** * Per-process confinement facts retained until settlement. Providers may * vary enforcement and diagnostic dialect between overlapping calls, so a @@ -58,11 +56,9 @@ export class SandboxBashExecutor extends LocalBashExecutor { constructor(ctx: Context, config: Config) { super(ctx, config) - // The sandbox default (mode + workspaceRoot) is the one shared policy home - // both enforcing families read; injecting sandboxPolicy guarantees it is - // constructed first. workspaceRoot arrives already resolved absolute. + // The default mode is the capability fact used for schema advertisement; + // actual tool executions carry their resolved per-call policy. this.mode = ctx.sandboxPolicy.defaultMode - this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot } /** The configured default mode — the capability fact the tool layer reads. */ @@ -71,24 +67,22 @@ export class SandboxBashExecutor extends LocalBashExecutor { } /** - * Stamp the effective mode onto the spec — the request's explicit override - * (an approved escalation), else this executor's configured default — so - * defaulting stays an explicit resolve step and `run()`/`start()` read the - * spec, never the config. + * Stamp a complete per-call policy onto the spec. Tool calls supply the + * calling session's resolved mode and root; lower-level callers fall back to + * the deployment policy. */ override resolve(request: BashExecRequest): BashExecSpec { - return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode } + return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() } } override async run(spec: BashExecSpec): Promise { - // resolve() always stamps the mode; the cast records that invariant - // (mirrors the constructor's config casts). - const mode = spec.sandboxMode as SandboxMode + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy if (mode === 'danger-full-access') { const result = await super.run(spec) return { ...result, sandbox: { mode, denied: false } } } - const confined = this.confine(spec.command, mode) + const confined = this.confine(spec.command, { ...policy, mode }) const result = await super.run({ ...spec, command: confined.command }) // Runner failure outranks denial because the command did not run. Throw the // same fail-closed error as confine-time discovery with the first stderr line. @@ -99,11 +93,11 @@ export class SandboxBashExecutor extends LocalBashExecutor { } override start(spec: BashExecSpec): BashProcess { - // Same stamped-by-resolve invariant as run(). - const mode = spec.sandboxMode as SandboxMode + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy if (mode === 'danger-full-access') return super.start(spec) // Install facts synchronously; promise settlement cannot run before start() returns. - const confined = this.confine(spec.command, mode) + const confined = this.confine(spec.command, { ...policy, mode }) const proc = super.start({ ...spec, command: confined.command }) const { enforcement, denialSignatures, runnerFailureSignatures } = confined this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures }) @@ -138,13 +132,13 @@ export class SandboxBashExecutor extends LocalBashExecutor { * `exec`s into the runner, so no extra shell lingers). Provider errors * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. */ - private confine(command: string, mode: ConfinedSandboxMode): { + private confine(command: string, policy: SandboxPolicy): { command: string enforcement: SandboxEnforcement denialSignatures: readonly string[] runnerFailureSignatures: readonly string[] } { - const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot }) + const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy) return { command: `exec ${confined.argv.map(shellQuote).join(' ')}`, enforcement: confined.enforcement, diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 32a246e42e..87bcffe9df 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -89,7 +89,7 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx. expect(strict.exitCode).not.toBe(0) expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false) - const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' })) + const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } })) expect(retried.exitCode).toBe(0) expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated') diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index b8c86d95b2..3ce944b07c 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -94,7 +94,7 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug expect(strict.exitCode).not.toBe(0) expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement }) expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false) - const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' })) + const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } })) expect(retried.exitCode).toBe(0) expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement }) expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated') diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 5b2b3ba16a..6e8f2229a0 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' @@ -72,6 +72,10 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult { return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) } } +function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy { + return { mode, workspaceRoot } +} + describe('the provider hand-off', () => { it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => { const { bash, calls } = await setup() @@ -147,30 +151,31 @@ describe('danger-full-access', () => { }) }) -describe('per-call sandboxMode override (the escalation mechanism)', () => { +describe('per-call sandbox policy (the session and escalation carrier)', () => { it('exposes the configured default as the capability fact, and resolve() stamps it', async () => { const { bash } = await setup() expect(bash.sandboxMode).toBe('read-only') - expect(bash.resolve({ command: 'true' }).sandboxMode).toBe('read-only') + expect(bash.resolve({ command: 'true' }).sandboxPolicy).toEqual(executionPolicy('read-only')) }) - it('an explicit override outranks the default at resolve(), and the wrap policy follows it', async () => { + it('an explicit policy outranks the default at resolve(), and the wrap follows its mode and root', async () => { const { bash, calls } = await setup() - expect(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }).sandboxMode).toBe('workspace-write') - await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' })) + const explicit = executionPolicy('workspace-write', '/session/project') + expect(bash.resolve({ command: 'true', sandboxPolicy: explicit }).sandboxPolicy).toEqual(explicit) + await bash.run(bash.resolve({ command: 'true', sandboxPolicy: explicit })) await bash.run(bash.resolve({ command: 'true' })) - expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', 'read-only']) + expect(calls.map(call => call.policy)).toEqual([explicit, executionPolicy('read-only')]) }) it('an escalated run reports the mode it ACTUALLY ran under', async () => { const { bash } = await setup() - const result = await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' })) + const result = await bash.run(bash.resolve({ command: 'true', sandboxPolicy: executionPolicy('workspace-write') })) expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) }) it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => { const { bash, calls } = await setup() - const result = await bash.run(bash.resolve({ command: 'echo free', sandboxMode: 'danger-full-access' })) + const result = await bash.run(bash.resolve({ command: 'echo free', sandboxPolicy: executionPolicy('danger-full-access') })) expect(result.stdout.text).toBe('free\n') expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false }) expect(calls).toHaveLength(0) @@ -181,7 +186,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => { // once — anything keyed off the configured default would misreport the // escalated one at its settle stamp. const { bash } = await setup() - const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxMode: 'workspace-write' })) + const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') })) const plain = bash.start(bash.resolve({ command: 'true' })) await plain.done await escalated.done @@ -191,7 +196,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => { it('an escalated danger-full-access background task carries no facts (nothing confined it)', async () => { const { bash, calls } = await setup() - const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' })) + const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') })) await task.done expect(task.sandbox).toBeUndefined() expect(task.readOutput().delta).toContain('bg-free') diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 7e08ea0365..6c212ee546 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -91,7 +91,7 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug expect(strict.exitCode).not.toBe(0) expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false) - const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' })) + const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } })) expect(retried.exitCode).toBe(0) expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated') diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ec5005ec70..73fbb4fb3e 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -27,7 +27,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp ## Vocabulary -`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing. +`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing. The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 55beccacea..a504513417 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -4,7 +4,7 @@ * @module dsh-bash/types */ -import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' /** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ export const DSH_ENV_PREFIX = 'DSH_' as const @@ -75,8 +75,8 @@ export interface BashExecRequest { * reject non-`DSH_*` names supplied through this managed channel. */ dshEnv?: DshEnvironment | undefined - /** Explicit per-call sandbox mode override. */ - sandboxMode?: SandboxMode | undefined + /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ + sandboxPolicy?: SandboxExecutionPolicy | undefined } /** @@ -106,8 +106,8 @@ export interface BashExecSpec { env?: Record | undefined /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ dshEnv?: DshEnvironment | undefined - /** Resolved sandbox mode; ignored by executors that do not confine. */ - sandboxMode: SandboxMode | undefined + /** Resolved sandbox policy; ignored by executors that do not confine. */ + sandboxPolicy: SandboxExecutionPolicy | undefined } /** One captured stream: the (possibly truncated) text plus recovery info. */ diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 63d9533410..cacfe85eca 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -17,7 +17,7 @@ class StubExecutor extends BashExecutor { timeoutMs: request.timeoutMs ?? 1000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, ...request.signal ? { signal: request.signal } : {}, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } @@ -55,7 +55,7 @@ describe('BashExecutor service seam', () => { const ctx = new Context() await ctx.plugin(StubExecutor) const spec = ctx.bash.resolve({ command: 'echo hi' }) - expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxMode: undefined }) + expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxPolicy: undefined }) const result = await ctx.bash.run(spec) expect(result.exitCode).toBe(0) diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 5e0ceb3826..f83df567f4 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -17,12 +17,12 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th | `command` | string (required) | Run via `bash -c`. No state persists between calls — use `workdir`, not `cd`. | | `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. | | `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. | -| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. | +| `workdir` | string | Working directory for this call. Defaults to the filesystem identity of the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. | | `run_in_background` | boolean | Return a task id immediately; no timeout applies. | | `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the wider mode a denied command needs, from the closed target vocabulary `workspace-write`/`danger-full-access` (never cut down to the executor's default — the effective mode is per-session; strict widening is checked at execution against it, and a non-widening request fails without prompting anyone). | | `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. | -`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. +`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently. ### Managed shell environment diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 8426e770ba..094e594661 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -18,9 +18,9 @@ import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-user-approval' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -299,11 +299,18 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | } /** - * Resolve an explicit workdir first, making a relative one session-cwd-relative; - * otherwise use the session cwd and leave executor defaulting as the fallback. + * Resolve an explicit workdir first, making a relative one session-workspace-relative; + * otherwise use the filesystem identity of the session cwd and leave executor + * defaulting as the fallback. A resolved sandbox-policy root wins so workdir + * and confinement use the exact same per-call identity. */ -function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined { - const sessionCwd = exec.agent?.session.header.cwd +function resolveWorkdir( + modelWorkdir: string | undefined, + exec: { agent?: Agent }, + policyWorkspaceRoot?: string, +): string | undefined { + const headerCwd = exec.agent?.session.header.cwd + const sessionCwd = policyWorkspaceRoot ?? (headerCwd === undefined ? undefined : canonicalPath(headerCwd)) if (modelWorkdir === undefined) return sessionCwd if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) { return resolvePath(sessionCwd, modelWorkdir) @@ -330,9 +337,14 @@ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS + const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (defaultMode !== undefined && sandboxPolicy === undefined) { + throw new Error('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing') + } - const sessionOverride = (exec: ToolExecution): SandboxMode | undefined => - defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events) + /** Resolve the complete standing policy for this call when a confining executor is mounted. */ + const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined => + sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) /** * Resolve a sandbox-escalation request through `ctx.approval` BEFORE @@ -342,14 +354,19 @@ export function apply(ctx: Context, config: Config = {}): void { * guard (the fields are unadvertised without a sandboxing executor, yet * schema validation checks advertised keys only, so an unadvertised * `sandbox_permissions` still reaches execute) and the approval ingredients - * — the seam is consumed opportunistically (`ctx.get`) so a deployment - * without it degrades per call. + * The shared policy resolver is required whenever the executor advertises + * confinement, so a split composition fails at tool-plugin load. */ - const approveBashEscalation = (mode: string, justification: string, exec: ToolExecution): Promise => { + const approveBashEscalation = ( + mode: string, + justification: string, + exec: ToolExecution, + standingPolicy: SandboxExecutionPolicy | undefined, + ): Promise => { if (escalationModes.length === 0) { throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') } - const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode + const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode return approveEscalation( { requestedMode: mode, justification, effectiveMode, subject: 'command' }, { @@ -401,17 +418,21 @@ export function apply(ctx: Context, config: Config = {}): void { async execute(args: BashToolArgs, exec) { validateBashArgs(args) // Description is display metadata; workdir defaults to the caller's session. - const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined - ? await approveBashEscalation(args.sandbox_permissions, args.justification, exec) - : sessionOverride(exec) - const workdir = resolveWorkdir(args.workdir, exec) + const standingPolicy = resolveSandboxPolicy(exec) + const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined + ? await approveBashEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy) + : undefined + const policy = approvedMode === undefined + ? standingPolicy + : { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode } + const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot) const dshEnv = bashEnv.collect(exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, dshEnv, - ...sandboxMode !== undefined ? { sandboxMode } : {}, + ...policy !== undefined ? { sandboxPolicy: policy } : {}, } if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 9c9e06bb31..f430ca07a1 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' import { renderProcessRead, renderResult } from '../src/render.ts' @@ -107,12 +108,12 @@ class RecordingSandboxExecutor extends BashExecutor { stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, timeoutMs: request.timeoutMs ?? 1000, ...request.signal ? { signal: request.signal } : {}, - sandboxMode: request.sandboxMode ?? 'read-only', + sandboxPolicy: request.sandboxPolicy ?? { mode: 'read-only', workspaceRoot: process.cwd() }, } } run(spec: BashExecSpec): Promise { - this.modes.push(spec.sandboxMode) + this.modes.push(spec.sandboxPolicy?.mode) return Promise.resolve({ exitCode: 0, signal: null, @@ -121,18 +122,18 @@ class RecordingSandboxExecutor extends BashExecutor { timeoutMs: spec.timeoutMs, stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false }, - sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false }, + sandbox: { mode: spec.sandboxPolicy?.mode ?? 'read-only', denied: false }, }) } start(spec: BashExecSpec): BashProcess { - this.modes.push(spec.sandboxMode) + this.modes.push(spec.sandboxPolicy?.mode) return { status: 'completed', exitCode: 0, signal: null, done: Promise.resolve(), - sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false }, + sandbox: { mode: spec.sandboxPolicy?.mode ?? 'read-only', denied: false }, readOutput: () => ({ delta: '', lossy: false }), kill: () => false, } @@ -149,7 +150,7 @@ class CountingStartExecutor extends BashExecutor { workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } @@ -175,6 +176,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(AgentRegistry) await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) await ctx.plugin(ToolBash) @@ -532,6 +534,14 @@ describe('sandbox escalation through the generic task producer', () => { justification: 'the command needs workspace writes', } + it('fails load when a confining executor has no shared sandbox-policy resolver', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingSandboxExecutor) + await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing') + }) + it('advertises the sandbox fields and validates their pairing', async () => { const { ctx } = await setupSandboxed() const schema = ctx.tools.schemas().find(item => item.name === 'bash')! @@ -993,7 +1003,7 @@ describe('the model-facing bash tool builds its request from named args only (no ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } run(): Promise { diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index cfe932ed65..d3011ed85f 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -1,42 +1,51 @@ # AGENTS.md — Web client stack -Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md); read the two architecture notes linked below before structural changes. +Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md). Before touching slots, component props, stores, or plugin structure, read the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) (the definitive composition model) and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) (loading chain, object layer, services). Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-`. +## Slot and props discipline + +The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code: + +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. +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.) +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. + +## Export discipline (client plugin packages) + +The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments): + +1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType`). Types are the extra allowance: contract types (owner shares, injected shapes, view/toolview entry types) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. +2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile. +3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot, the view and toolview registries) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. + +## ctx discipline (components never see ctx) + +`ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks **through the four props shares**; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook. + ## Layering red lines -The stack is three layers with one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md): +The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md): -1. **Data object layer** (`web-runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable. -2. **Hooks layer** (`web-ui/src/hooks`, pure data): subscribes to object snapshots via `useSyncExternalStore`, exposes plain-data handles. No JSX, no DOM. -3. **Presentation components** (`web-ui`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; they receive data and callbacks through props only. +1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable. +2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all. +3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares. Non-negotiables across the layers: -- **No business objects in the store.** zustand carries cross-view presentation state only (`rpcLog`, `ui`, `connection` slices). Sessions, frames, and connections live in the object layer. View-local facts (selection, expansion) stay in component state, not the store. +- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer. - **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest

`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). -- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `web-runtime/src/session/notifier.ts`. +- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`. - **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule). -## Directory regime (`web-ui/src`) +## Directory regime (plugin packages) -> Shell restructure in progress: the tree is converging to this layout (today's `components/{conversation,sessions,panels}` migrate into it); the regime below is the target every new feature follows now. - -Two-level feature directories, one contributor per directory — physical conflict avoidance: - -``` -web-ui/src/ - shell/ # AppShell + the three slot registries + builtins - leftmenu// # one directory per left-nav bar (sessions, rpclog, …) - sessiontabs// # one directory per session tab (conversation, gantt, …) - components/ # shared leaves (MessageText, JsonBlock, …) - hooks/ utils/ style/ # cross-cutting; not feature-owned -``` - -- `leftmenu/` must not import `leftmenu/` or `sessiontabs/*` (and vice versa). Anything two features need sinks into `components/`. -- Bars, tabs, and detail blocks register through the `shell/` registries (module-level map, `register*()` returns the disposer — same shape as `toolCardRegistry`). v1 registration is static in `shell/builtins.ts`; plugin-driven registration later calls the same functions. -- **Claiming a placeholder slot**: pick a `placeholder: true` tab (or add a bar) in `shell/builtins.ts`, create your feature directory, and replace the placeholder component with your container. Don't build features outside this regime. +One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through the slot/view/toolview registries in `apply` — never module-level side effects. ## Styling @@ -63,9 +72,9 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore ## New component checklist -1. Claim the slot (see the directory regime above): one feature, one directory. -2. Build the container in your feature directory; keep leaves pure-props. Wire data through the hooks layer, not by importing business objects into components. -3. Copy a neighbouring jsdom spec into `web-ui/tests/`, keep it behavior-shaped: start from the happy path and the edge states, then widen until the component's branches are covered — the coverage gate applies; only the assertion style stays behavior-level. +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. +2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. +3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery. 4. Tokens only in CSS; Chinese product copy; English comments. 5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface). -6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the three GUI notes above are the precedents to extend. +6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index a35c9c3d0c..dc9fbb85b8 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-connection -Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2. +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. ## Model Experience diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7d4a93e888..bbf4809466 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -69,8 +69,9 @@ function buildAlphaLog(): SessionEvent[] { } push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card - // type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample. + // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in + // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above + // stays presenter-less as the unknown fallback. const toolTurn = (turn: number, name: string, args: string, resultText: string): void => { const callId = `fx-call-${turn}` push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) @@ -87,7 +88,8 @@ function buildAlphaLog(): SessionEvent[] { } toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') - toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录') + toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') + toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入') return events as unknown as SessionEvent[] } @@ -112,8 +114,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { card: 'diff', title: `Write ${str(args.path)}`, diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }], } - case 'fx-note': - return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args } + case 'edit': + return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args } + case 'write': + return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args } default: return undefined // echo et al: the documented no-view fallback path } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 026fc1bf5f..b017d1c9e2 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -21,16 +21,13 @@ export type { ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' -export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts' +export { RpcId, AbstractApiClient, transportError } from './api.ts' -// ---- Connection loop ---- -export { ConnectionController } from './connection.ts' +// ---- Connection loop types (part of the ConnectionHandle.start contract; +// the controller class itself stays package-internal — apply owns the loop, +// tests reach it via src) ---- export type { ConnectionConfig, ConnectionSinks, ConnectionState } -// ---- Platform client subclasses ---- -export { WebApiClient } from './web-api-client.ts' -export { FixtureApiClient, createFixtureApi } from './fixture.ts' - /** Required services (none — this is the wire root). */ export const inject: string[] = [] diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 3f45ca0231..8b0357cf97 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -1,14 +1,8 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types", - "lib": [ - "ES2024", - "DOM", - "DOM.Iterable" - ], - "types": [] + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/client/i18n/package.json b/packages/client/i18n/package.json index 1e3d81b275..5b5d4e718c 100644 --- a/packages/client/i18n/package.json +++ b/packages/client/i18n/package.json @@ -33,7 +33,7 @@ }, "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-client-web-react": "workspace:^" + "@deepseek-ai/dsh-client-runtime": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts index b994cccace..37e1c0cdb5 100644 --- a/packages/client/i18n/src/client/index.ts +++ b/packages/client/i18n/src/client/index.ts @@ -5,8 +5,13 @@ * Contract: api-contracts v3 section 8. */ import type { Context } from 'cordis' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react' +// The snapshot-store engine lives in runtime (store relocation): framework +// data stores like this locale cell use it directly. The store carries no +// hook — a React consumer binds a selector hook via web-react's +// bindSnapshotSelector at its own seam (none exists today; the current +// consumers are translate() reads and test-side subscribe/set). +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { en } from '../locales/en.ts' import { zh } from '../locales/zh.ts' diff --git a/packages/client/i18n/tsconfig.json b/packages/client/i18n/tsconfig.json index dd593f678d..63eb9779ca 100644 --- a/packages/client/i18n/tsconfig.json +++ b/packages/client/i18n/tsconfig.json @@ -1,14 +1,8 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types", - "lib": [ - "ES2024", - "DOM", - "DOM.Iterable" - ], - "types": [] + "outDir": "lib/types" }, "include": [ "src" @@ -18,7 +12,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../web-react" + "path": "../runtime" }, { "path": "../../support/invariants" diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 01c4172902..66eb5b22ac 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,6 @@ # @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), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. +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. ## Model Experience diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index d17ff8e4b4..384b2e2e3c 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -37,10 +37,11 @@ "dependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "immer": "^10.1.1", "react": "^18.2.0", - "@deepseek-ai/dsh-session": "workspace:^" + "zustand": "~4.4.7" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/runtime/src/client/contract/store.ts b/packages/client/runtime/src/client/contract/store.ts new file mode 100644 index 0000000000..ce4444cf36 --- /dev/null +++ b/packages/client/runtime/src/client/contract/store.ts @@ -0,0 +1,244 @@ +/** + * Snapshot store engine (zustand vanilla + immer + subscribeWithSelector + + * rafFlush middleware + opt-in persist + dev freeze) plus the declarative + * shell over it: {@link defineStore} bakes an init/persist/actions literal + * into a {@link StoreHandle}, the registration-side store seat of the slot + * terminal design (§4). Lives in the React-free runtime (store-migration + * ruling: the data layer owns its engine; web-react is shell-only React + * glue): engine products are bare observables — subscribe/getSnapshot/ + * update/set, NO selector hook. Hook synthesis is web-react's (the one + * uSES bridge, cached per source at the binding site). + */ +import { createStore, type StoreApi } from 'zustand/vanilla' +import { subscribeWithSelector } from 'zustand/middleware' +import { shallow } from 'zustand/shallow' +import { produce } from 'immer' +import type { + ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec, +} from '@deepseek-ai/dsh-client-ui-slots' + +// Store contract types are ui-slots authority; re-exported beside the engine +// so store consumers get one import surface. +export type { + ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec, +} from '@deepseek-ai/dsh-client-ui-slots' + +/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */ +export interface ObservableSnapshot { getSnapshot(): T; subscribe(fn: () => void): () => void } + +/** Writable snapshot store (bare data face; React selector hooks are synthesized in web-react). */ +export interface SnapshotStore extends ObservableSnapshot { + /** + * Mutate the state through an immer draft. + * @param mutator - draft mutator. + */ + update(mutator: (draft: T) => void): void + /** + * Replace the state wholesale. + * @param next - next state. + */ + set(next: T): void +} + +/** + * Shallow equality for selector slices (zustand/shallow semantics; travels + * with the engine so hook consumers need no zustand dependency). + * @param a - left value. + * @param b - right value. + * @returns whether the values are shallowly equal. + */ +export function shallowEqual(a: unknown, b: unknown): boolean { + return shallow(a, b) +} + +/** Batches subscriber notification into one flush per animation frame. */ +function rafBatch(notify: () => void): () => void { + // Fall back to microtask batching where rAF is absent (node unit tests); + // both preserve the N-changes=1-notification contract within a tick. + const schedule: (fn: () => void) => void = + typeof requestAnimationFrame === 'function' + ? (fn) => { requestAnimationFrame(() => { fn() }) } + : (fn) => { queueMicrotask(fn) } + let scheduled = false + return () => { + if (scheduled) return + scheduled = true + schedule(() => { + scheduled = false + notify() + }) + } +} + +/** + * Create a snapshot store. + * + * Flush default is 'sync' (controlled inputs need same-tick echo); frame-driven + * stores opt into 'raf', where a frame's worth of updates coalesces into one + * notification. Known raf-mode tradeoff: a component mounting mid-frame reads + * fresh state while existing subscribers hear it next flush — transient + * frame-level skew, same nature as the object layer's microtask batching. + * + * @param init - initial state. + * @param opts - flush mode and opt-in persistence (localStorage, keyed by name). + * @returns the store. + */ +export function createSnapshotStore( + init: T, opts?: { flush?: 'raf' | 'sync'; persist?: { name: string } }): SnapshotStore { + // Immer enters through produce() in update() below (identical semantics to + // the immer middleware without its setState-signature mutator generics). + const withSelector = subscribeWithSelector(() => init) + const api: StoreApi = createStore()(withSelector) + if (opts?.persist) attachPersistence(api, opts.persist.name) + + let subscribe = (fn: () => void) => api.subscribe(fn) + if (opts?.flush === 'raf') { + const listeners = new Set<() => void>() + const flush = rafBatch(() => { for (const fn of [...listeners]) fn() }) + api.subscribe(flush) + subscribe = (fn: () => void) => { + listeners.add(fn) + return () => { listeners.delete(fn) } + } + } + + return { + getSnapshot: () => api.getState(), + subscribe: fn => subscribe(fn), + update: (mutator) => { + // Immer's produce (not setState's partial-merge path) so scalar and + // array roots replace correctly; produce also freezes in dev. + api.setState(produce(api.getState(), (draft) => { mutator(draft as T) }), true) + }, + set: (next) => { + api.setState(devFreeze(next), true) + }, + } +} + +/** + * Whole-value JSON persistence to localStorage. Hand-rolled instead of the + * zustand persist middleware: its write path spreads state into an object + * (`partialize({ ...get() })`), exploding primitive state (a persisted string + * draft becomes {0:'h',1:'e',...}) — not fixable via merge/deserialize options + * because the corruption happens before serialization. Storage failures + * (quota, private mode) only disable persistence, never break the store. + */ +function attachPersistence(api: StoreApi, name: string): void { + // Non-browser runs (node e2e booting the client tree) have no localStorage: + // persistence silently disables — same contract as a storage failure, minus + // the per-store console noise a ReferenceError would produce. + if (typeof localStorage === 'undefined') return + try { + const raw = localStorage.getItem(name) + if (raw !== null) { + api.setState(devFreeze(JSON.parse(raw) as T), true) + } + } catch (error) { + console.error(`snapshot store '${name}' rehydration failed:`, error) + } + api.subscribe((state) => { + try { + localStorage.setItem(name, JSON.stringify(state)) + } catch (error) { + console.error(`snapshot store '${name}' persistence failed:`, error) + } + }) +} + +/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */ +function devFreeze(value: T): T { + if (process.env.NODE_ENV === 'production') return value + deepFreeze(value) + return value +} + +function deepFreeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return + Object.freeze(value) + for (const key of Reflect.ownKeys(value)) { + deepFreeze((value as Record)[key]) + } +} + +// ---- defineStore shell (slot terminal design §4) ---- +// The type authority is ui-slots' store family (create(scopeKey?) and +// clearPersisted() included); this module houses only the engine-backed +// implementation. The one engine-side widening left: instances expose the +// raw engine store for framework/test surfaces. + +/** A live engine instance: the contract instance plus the raw engine store. */ +export interface EngineStoreInstance> extends StoreInstance { + /** The underlying engine store (framework/test surface; components never see it). */ + readonly store: SnapshotStore +} + +/** The engine-backed handle: create() narrowed to the engine instance. */ +export interface EngineStoreHandle> extends StoreHandle { + /** + * Construct a live engine instance (see the contract JSDoc on + * {@link StoreHandle.create} for scopeKey/persist semantics). + * + * Known boundary: the persist key is the storage identity, so multiple live + * instances created under the same resolved key share (and cross-pollute) + * one localStorage entry. Instance uniqueness per key is the caller's + * responsibility — production is safe because the framework caches one + * instance per handle x scope key; tests wanting isolation use distinct + * scope keys or persist-free declarations (multi-create freedom is a + * feature there, so create() deliberately does not dedupe or throw). + * @param scopeKey - session id for session-scope instances; omitted for root scope. + * @returns the engine instance. + */ + create(scopeKey?: string): EngineStoreInstance +} + +/** + * Declare a store: initial state, optional persistence, and the full write + * set as pure draft mutators. The returned handle is the registration + * currency of the store seat — its identity keys instance sharing. Satisfies + * ui-slots' DefineStore contract (the handle/instance are the engine-extended + * subtypes). + * + * The `A & ActionsDecl` actions position is load-bearing: T resolves from + * `init` in the first inference round, and the intersection then contextually + * types each mutator's draft parameter (context-sensitive functions defer), + * so call sites write `(d, x: X) => { ... }` with no draft annotation. If a + * future TS version breaks this single-literal inference, the design's + * documented fallback is currying (`defineStore(init).actions({...})`). + * @param decl - init lambda (fresh state per instance), optional persist key, actions table. + * @returns the store handle. + */ +export function defineStore>( + decl: StoreSpec & { actions: A & ActionsDecl }): EngineStoreHandle { + return { + spec: decl, + create(scopeKey?: string): EngineStoreInstance { + const persistKey = decl.persist === undefined + ? undefined + : scopeKey === undefined ? decl.persist : `${decl.persist}.${scopeKey}` + const store = createSnapshotStore( + decl.init(), + persistKey !== undefined ? { persist: { name: persistKey } } : undefined) + const actions = {} as Record void> + for (const key of Object.keys(decl.actions)) { + const mutate = decl.actions[key] as (draft: T, ...params: unknown[]) => void + actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) } + } + return { + actions: actions as BakedActions, + getSnapshot: () => store.getSnapshot(), + subscribe: fn => store.subscribe(fn), + store, + clearPersisted: () => { + if (persistKey === undefined || typeof localStorage === 'undefined') return + try { + localStorage.removeItem(persistKey) + } catch { + // Storage failures (private mode, quota teardown races) only skip + // cleanup — the same non-fatal contract as attachPersistence. + } + }, + } + }, + } +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 6c012fa410..77b51a68b5 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,30 +1,40 @@ /** * Browser half: the whole runtime contract surface (api-contracts v3 §4) — - * SlotsService, SessionsService (list store + scope tree + object layer), - * the ClientLoader interface, and the cordis Context/Events merges. apply + * SlotsService (declaration ledger + renderer seam + store axis, built-in + * 'root'), SessionsService (list store + current selection + scope tree + + * object layer), the ClientLoader interface, and the cordis Context/Events + * merges. apply * mounts ctx.slots + ctx.sessions and wires the connection stream loop into * the object layer. The loader machinery implementation is NOT in the plugin * bundle — it ships via the package's `./loader` subpath, statically held by * the web shell (a loader cannot load itself). */ import type { Context } from 'cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots' -import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react' +import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotStore } from './contract/store.ts' import { SlotsService } from './slots.ts' import { SessionsService } from './sessions/service.ts' +import type { SessionListState } from './sessions/service.ts' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' export { SlotsService } from './slots.ts' +// RootOwnerProps rides the 'root' SlotMap row (both migrated here from +// ui-layout: the framework slot is declared by the framework package). +export type { RootOwnerProps } from './slots.ts' export { SessionsService, scopeOf } from './sessions/service.ts' +export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' -export { SessionManager } from './sessions/manager.ts' -export type { SessionListSnapshot } from './sessions/manager.ts' -export { Session, PAGE_MESSAGES } from './sessions/session.ts' -export type { SessionListEntry } from './sessions/lineage.ts' +// The snapshot-store engine lives here since the store migration (the data +// layer owns its substrate; web-react is React glue only). The './client' +// main export is the single serving door — no store subpath. +export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' +export type { + EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, +} from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot, - OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode, + PendingInteraction, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' @@ -41,11 +51,8 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' */ export type ClientContext = Context -/** SessionBinding narrowed to the client context (inject factories dot services directly). */ -export type ClientSessionBinding = GenericSessionBinding - /** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */ -export type UseConversationSession = UseSession +export type UseConversationSession = SnapshotSelectorHook /** * One tool call as the chat flow renders it: still-running (spinner card) or @@ -54,6 +61,25 @@ export type UseConversationSession = UseSession */ export type ToolCallBlock = RunningToolCall | ToolResultNode +declare module '@deepseek-ai/dsh-client-ui-slots' { + /** + * Session standard kit, real members (ui-slots declares the empty seat; + * the runtime — where the subjects live — merges the concrete types): + * every session-scope slot component receives these from the framework. + */ + interface SessionStandardProps { + /** Selector hook over this session's conversation snapshot. */ + useSession: SnapshotSelectorHook + /** The framework-resolved session id (owners never pass it). */ + sessionId: SessionId + } + /** Global standard kit, real members: the session-list hook every slot component receives. */ + interface GlobalStandardProps { + /** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */ + useSessions: SnapshotSelectorHook + } +} + declare module 'cordis' { interface Events { /** diff --git a/packages/client/runtime/src/client/loader/index.ts b/packages/client/runtime/src/client/loader/index.ts index 521c597ae4..81148a7dfc 100644 --- a/packages/client/runtime/src/client/loader/index.ts +++ b/packages/client/runtime/src/client/loader/index.ts @@ -17,7 +17,7 @@ * load one by one in inject topology. */ import type { Context } from 'cordis' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '../contract/store.ts' import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts' export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts' diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b4cf9677e..ec63b8f70d 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -1,7 +1,9 @@ /** * SessionsService: root sessions service — list snapshot store (manager - * projection), session scope tree (mintScope pattern: no-op plugin Fiber + - * ctx.extend scope tag), stable SessionBinding cache, ancestry walk. + * projection; carries `current`, the persisted selection every + * session-scoped surface keys off — migrated here from ui-layout per the + * slot-parity design), session scope tree (mintScope pattern: no-op plugin + * Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk. * * Scope lifecycle is watch-driven: a scope is minted lazily on first * resolution; a session leaving the list tears its scope down only when @@ -13,8 +15,9 @@ */ import type { Context, Fiber } from 'cordis' import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react' +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 { Session } from './session.ts' @@ -28,8 +31,12 @@ export interface SessionSummary { updatedAt: number } -/** Session list store shape. */ -export interface SessionListState { ids: SessionId[]; byId: Record } +/** + * Session list store shape. `current` rides the same snapshot (arbitrated: + * 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 } /** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ export interface SessionBinding { @@ -69,15 +76,26 @@ interface ScopeRecord { fiber: Fiber ctx: Context binding: SessionBinding + /** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */ + cell: SessionCell } -/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */ +/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */ export class SessionsService { - /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */ + /** 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 + /** + * Persisted selection cell (the durable half of `list.current`). Private on + * purpose: reads go through the list snapshot; writes through {@link + * SessionsService.open}. Projection validates it against the live list + * instead of destructively pruning, so a selection survives transient list + * states (reconnect re-pull) and resurfaces when its session returns. + */ + private readonly selection: SnapshotStore<{ sessionId?: SessionId }> + private readonly scopes = new Map() /** Most recently resolved binding id — the watch approximation for deferred teardown. */ private watched: SessionId | undefined @@ -90,13 +108,29 @@ export class SessionsService { */ constructor(private readonly rootCtx: Context, api: IApiClient) { this.manager = new SessionManager(api) - this.list = createSnapshotStore({ ids: [], byId: {} }) + this.selection = createSnapshotStore<{ sessionId?: SessionId }>( + {}, + { persist: { name: 'dsh.sessions.current' } }) + this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. this.manager.subscribe(() => { this.projectList() }) rootCtx.reflect.provide('sessions', this, undefined) } + /** + * Select a session as current. Unknown ids fail loud instead of navigating + * nowhere (the sole selection write path). + * @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 }) + } + /** * Create a session on the host. * @param opts - creation options (project directory). @@ -132,6 +166,23 @@ export class SessionsService { return record.binding } + /** + * Resolve the render-layer session cell (SessionProvider's feed through + * the renderer host; ctx never enters the render layer). Marks the session + * watched, same as {@link SessionsService.binding}. + * @param id - session id. + * @returns cell, or undefined for a session neither listed nor already scoped. + */ + cell(id: string): SessionCell | undefined { + const record = this.resolve(id as SessionId) + if (record === undefined) return undefined + if (this.watched !== id) { + this.watched = id as SessionId + this.sweepDeferred() + } + return record.cell + } + /** * Breadcrumb feed: walk parentId links inside the list store. * @param id - session id. @@ -158,10 +209,14 @@ export class SessionsService { if (this.list.getSnapshot().byId[id] === undefined) return undefined const fiber = this.rootCtx.plugin(sessionScope) const ctx = fiber.ctx.extend({ [kScope]: id }) + const session = this.manager.get(id) const record: ScopeRecord = { fiber, ctx, - binding: { sessionId: id, session: this.manager.get(id), ctx }, + binding: { sessionId: id, session, ctx }, + // Bare source form (store migration): the Session object IS the + // observable; the React side binds the useSession hook per cell. + cell: { sessionId: id, session }, } this.scopes.set(id, record) return record @@ -183,7 +238,11 @@ export class SessionsService { ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), } } - this.list.set({ ids, byId }) + // 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 }) this.pruneScopes(byId) } @@ -197,10 +256,18 @@ export class SessionsService { } this.scopes.delete(id) this.deferredRemovals.delete(id) - void record.fiber.dispose() + this.dropScope(id, record) } } + /** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */ + private dropScope(id: SessionId, record: ScopeRecord): void { + void record.fiber.dispose() + // Optional lookup: slots and sessions are sibling services with no + // declared dependency; a slots-less boot (object-layer tests) skips. + this.rootCtx.get('slots')?.pruneStoreScope(id) + } + /** Run deferred teardowns whose session is no longer watched (called when the watch moves). */ private sweepDeferred(): void { for (const id of [...this.deferredRemovals]) { @@ -220,7 +287,7 @@ export class SessionsService { * future teardown path cannot double-dispose. */ if (record !== undefined) { this.scopes.delete(id) - void record.fiber.dispose() + this.dropScope(id, record) } } } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0934118bd1..761aca96c2 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -7,8 +7,7 @@ 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 } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-client-connection/client' -import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { ObservableSnapshot } from '../contract/store.ts' import type { ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall, } from './conversation.ts' @@ -19,11 +18,13 @@ import { PartialAccumulator } from './partial.ts' /** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */ export const PAGE_MESSAGES = 50 -/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */ +/** + * Per-session state owner: event window + fold + partial, snapshot out via + * subscribe/getSnapshot (see the web client architecture RFC). Bare source + * only (store migration): the React machinery binds the per-cell useSession + * hook at its own seam — no selector hook member lives on the data layer. + */ export class Session implements ObservableSnapshot { - /** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */ - readonly useSelector: SnapshotSelectorHook = bindSnapshotSelector(this) - // ---- Window and derived state (all private; the snapshot is the only read surface) ---- private events: SessionEvent[] = [] /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view). diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 5b58670bd4..700026df52 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -1,22 +1,84 @@ /** - * SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots). - * Every mutation re-emits as the 'slots/changed' cordis event; define/register - * run through the caller's ctx.effect so a plugin's registrations are - * collected when its fiber unloads (cordis-native cascade). + * SlotsService: the cordis Service layer of the slot system over the pure + * SlotCore (ui-slots owns registration semantics, the declaration ledger, + * the load-time validations, and the unload cascade). This layer owns what + * needs the runtime: the 'slots/changed' event bridge, register through the + * caller's ctx.effect (fiber unload collects registrations), the renderer + * install seam (install()/renderSlot('root') + the SlotRendererHost face), + * and the store INSTANCE axis — handle x scope key -> create/cache, dropped + * with the last holding entry, session instances cleared (with persisted + * state) on scope death. */ /* eslint-disable @typescript-eslint/no-redundant-type-constituents -- - * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty - * in this compilation unit (intersection reads `never`) but consumers merge - * keys in; the rule fires on the empty-map view, not on real redundancy. */ + * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only + * holds this package's 'root' row in this compilation unit, but consumers + * merge keys in; the rule fires on the narrow-map view, not on real + * redundancy. */ import { Service } from 'cordis' import type { Context } from 'cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' -import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext } from './index.ts' +import type { + OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, + SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike, +} from '@deepseek-ai/dsh-client-ui-slots' -/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** The built-in render-tree root hole (seeded by SlotCore): rendered only by the shell, occupied by a layout entry. */ + 'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps } + } +} + +/** Root owner share: the shell supplies nothing — the frame is inject-assembled. */ +export interface RootOwnerProps { children?: never } + +/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */ +const ROOT_INSTANCE_KEY = 'root' + +// FIXME(slot-parity): the engine's arbitrated persist extensions — create() +// takes the scope key (per-session localStorage suffix) and instances expose +// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike; +// these local structural faces bridge until fw-slots lifts them. + +/** Store handle face as the engine actually ships it (scope-key-aware create). */ +interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance } + +/** Engine instance face: the host-contract shape plus persisted-state cleanup. */ +interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void } + +/** Store axis record: one per live handle, dropped when the last holding entry unloads. */ +interface StoreAxisRecord { + /** Scope of the slot the handle mounted under (the core validated cross-scope conflicts). */ + scope: SlotScope + /** Live registrations holding the handle. */ + refs: number + /** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */ + instances: Map +} + +/** Type-erased options view the implementation works with (the typed overloads proved the shares). */ +interface ErasedRegisterOptions { + name: string + children?: Record> + store?: StoreDecl + inject?: (...args: never[]) => Record + key?: string + id?: string + order?: number + label?: string + registrant?: string +} + +/** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */ +interface ErasedCore { register(options: object, component: unknown): () => void } + +/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */ export class SlotsService extends Service { private readonly _core = new SlotCore() + /** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */ + private readonly _stores = new Map() + private _renderer: SlotRenderer | undefined + private _host: SlotRendererHost | undefined /** * @param ctx - owning root context. @@ -27,44 +89,92 @@ export class SlotsService extends Service { } /** - * Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber). - * @param key - SlotMap key. - * @param spec - kind/scope spec. - * @returns disposer. + * The single registration API. The typed face IS the core's register + * (both overloads reused verbatim — one authority, no structural copy; + * see SlotCore.register for children declaration, store seat, inject + * face, load-time validation, and the unload cascade). This layer adds: + * disposal through the caller's ctx.effect (fiber unload = cascade), + * exclusive-factory minting (`store: createXxxStore` becomes a per-entry + * handle), the registrant diagnostics stamp, and store-instance lifecycle + * on the entry axis. + * + * Declared here, implemented by prototype assignment below the class: it + * MUST stay a prototype method (never an instance arrow) — the cordis + * service proxy binds `this.ctx` to the CALLER's context at call time, + * which is what routes the effect (and the unload cascade) into the + * caller's fiber. An arrow property would freeze `this` to the service's + * own root ctx and silently break per-plugin disposal. */ - define(key: K, spec: SlotSpec): () => void { - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()') + declare readonly register: SlotCore['register'] + + /** + * Install the shell's renderer (web-react's createSlotRenderer product). + * Boot-once: a second install throws. Runs through the caller's ctx.effect, + * so shell fiber unload uninstalls the renderer. + * @param renderer - the outlet machinery implementing SlotRenderer. + */ + install(renderer: SlotRenderer): void { + if (this._renderer !== undefined) throw new Error('slot renderer already installed (install() is boot-once)') + this.ctx.effect(() => { + this._renderer = renderer + return () => { + if (this._renderer === renderer) this._renderer = undefined + } + }, 'slots.install()') } /** - * Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber). - * @param key - SlotMap key. - * @param component - contributed component. - * @param args - kind-shaped options (mandatory for keyed/list kinds); the - * inject factory's binding is pinned to ClientContext. - * @returns disposer. + * The single ctx-level render entry: the shell renders 'root'; every other + * key renders inside components through the props renderSlot face. All + * three guards are fail-loud boot-order checks, no fallback. + * @param key - must be 'root' (runtime-enforced for dynamically composed callers). + * @param owner - owner share for the root entry (the shell supplies {}). + * @returns the rendered root tree. */ - register>( - // Client-context registrations have exactly one ctx shape: pin Ctx to - // ClientContext so inject factories dot services without a cast. - key: K, component: SlotComponent>>, - ...args: RegisterArgs): () => void { - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return this.ctx.effect(() => this._core.register(key, component, ...args), 'slots.register()') + renderSlot(key: K, owner: OwnerOf): ReturnType { + // Widened: in this package's own program SlotMap holds only 'root', which + // would fold the guard to constant-false; the check exists for plain-JS + // and cross-program callers where K is wider. + if ((key as string) !== 'root') { + throw new Error(`ctx-level renderSlot only renders 'root' (got "${key}"); child slots render through the component props face`) + } + if (this._renderer === undefined) { + throw new Error("slot renderer not installed — boot must call ctx.slots.install(createSlotRenderer()) before rendering 'root'") + } + if (this._core.entries('root').length === 0) { + throw new Error("'root' has no registration — a layout entry must register into 'root' before the shell renders it") + } + return this._renderer.renderRoot(this.hostFace(), owner) } /** - * Snapshot entries for a key. - * @param key - SlotMap key. - * @returns registered entries (stable reference between mutations). + * Drop the per-session store instances of a dead session (the sessions + * service calls this on scope teardown; root-scoped records are untouched). + * Persisted state goes with the session — a never-rendered dead session can + * still own keys from an earlier page load, so the instance is materialized + * transiently just to clear storage (no-op for unpersisted stores). + * @param sessionId - the torn-down session. */ - entries(key: K): readonly SlotEntry[] { + pruneStoreScope(sessionId: string): void { + for (const [handle, record] of this._stores) { + if (record.scope !== 'session') continue + const instance = record.instances.get(sessionId) ?? handle.create(sessionId) + instance.clearPersisted() + record.instances.delete(sessionId) + } + } + + /** + * Snapshot entries for a key (render-erased view; stable reference between mutations). + * @param key - SlotMap key. + * @returns registered entries. + */ + entries(key: keyof SlotMap & string): readonly StoredEntry[] { return this._core.entries(key) } /** - * Look up a defined spec. + * Look up a declared spec (register-declared or the built-in 'root'). * @param key - SlotMap key. * @returns spec or undefined. */ @@ -72,15 +182,6 @@ export class SlotsService extends Service { return this._core.spec(key) } - /** - * Dynamic-key escape hatch for spec lookup (renderer-side string keys). - * @param key - candidate slot key. - * @returns wide-typed spec or undefined. - */ - specDynamic(key: string): SlotSpec | undefined { - return this._core.specDynamic(key) - } - /** * Subscribe to a key's registration changes (microtask-batched). * @param key - SlotMap key. @@ -100,8 +201,114 @@ export class SlotsService extends Service { return this._core.getVersion(key) } - /** The wrapped pure core (web-react's scopedSlots outlet reads through this). */ - get core(): SlotCore { - return this._core + /** Delegating registration path: factory minting + registrant stamp + core write + instance-axis bookkeeping. */ + private _register(options: ErasedRegisterOptions, component: unknown): () => void { + // Exclusive stores pass the factory itself: minted here into a per-entry + // handle so the stored entry always carries a resolvable handle (the + // core's shared-handle scope pinning applies to it harmlessly). + const store = typeof options.store === 'function' ? options.store() : options.store + const registrant = options.registrant ?? (this.ctx.fiber as { name?: string } | undefined)?.name + const erased: ErasedRegisterOptions = { + ...options, + ...(store !== undefined ? { store } : {}), + ...(registrant !== undefined ? { registrant } : {}), + } + // Core write first: all load-time validation (undeclared target, + // duplicate declaration, kind conflicts, cross-scope handle) throws + // there before this layer commits anything. + const dispose = (this._core as unknown as ErasedCore).register(erased, component) + if (store !== undefined) { + // Register succeeded, so the target's spec is on the ledger. + const scope = (this._core.specDynamic(options.name) as SlotSpec).scope + this._acquire(store, scope) + } + let disposed = false + return () => { + if (disposed) return + disposed = true + dispose() + if (store !== undefined) this._release(store) + } + } + + /** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */ + 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") + } + // 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. + const current = { + getSnapshot: () => sessions.list.getSnapshot().current as string | undefined, + subscribe: (fn: () => void) => sessions.list.subscribe(fn), + } + this._host = { + subscribe: (key, fn) => this._core.subscribe(key, fn), + getVersion: key => this._core.getVersion(key), + entriesOf: key => this._core.entries(key), + specOf: key => this._core.specDynamic(key), + isLive: entry => this._core.isLive(entry), + storeOf: (entry, scopeKey) => + entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), + sessions: { + list: sessions.list, + current, + cell: id => sessions.cell(id), + }, + } + return this._host + } + + /** Resolve (create or reuse) the store instance for a registered handle under a scope key. */ + private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike { + const record = this._stores.get(handle) + if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)') + const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY + if (key === undefined) throw new Error('session-scoped store resolution requires a session id') + let instance = record.instances.get(key) + if (instance === undefined) { + // Session instances get the scope key (the engine suffixes the persist + // key per session); root instances stay keyless. + instance = record.scope === 'session' ? handle.create(key) : handle.create() + record.instances.set(key, instance) + } + return instance + } + + /** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */ + private _acquire(handle: EngineStoreHandle, scope: SlotScope): void { + const record = this._stores.get(handle) + if (record === undefined) { + this._stores.set(handle, { scope, refs: 1, instances: new Map() }) + return + } + record.refs += 1 + } + + /** Drop one reference; the last holder's unload drops the record (instances go with it — engine stores need no explicit dispose). */ + private _release(handle: EngineStoreHandle): void { + const record = this._stores.get(handle) + /* v8 ignore next -- defensive: release only runs from a disposer whose + * register acquired the same handle, so the record must exist; kept so a + * future call site cannot underflow the axis. */ + if (record === undefined) return + record.refs -= 1 + if (record.refs === 0) this._stores.delete(handle) } } + +// register's implementation (prototype assignment pairs with the `declare` +// inside the class — see its JSDoc for why it must live on the prototype). +// Element access reaches the private _register legally and keeps it a +// TS-visible read. +;(SlotsService.prototype as { register: (options: object, component: unknown) => () => void }).register + = function register(this: SlotsService, rawOptions: object, component: unknown): () => void { + // The core's overloads proved the shares; the implementation works on + // the erased view (same pattern as the core's own implementation arm). + const options = rawOptions as ErasedRegisterOptions + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity + return this.ctx.effect(() => this['_register'](options, component), 'slots.register()') + } diff --git a/packages/client/web-react/src/env.d.ts b/packages/client/runtime/src/env.d.ts similarity index 100% rename from packages/client/web-react/src/env.d.ts rename to packages/client/runtime/src/env.d.ts diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index bb98bca98e..0baa2e8237 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -37,6 +37,9 @@ describe('runtime client apply', () => { it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', 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') expect(sessions !== undefined).toBe(true) expect(bench.sinks).toBeDefined() diff --git a/packages/client/runtime/tests/client-loader-bundle.e2e.ts b/packages/client/runtime/tests/client-loader-bundle.e2e.ts deleted file mode 100644 index b8932b0c62..0000000000 --- a/packages/client/runtime/tests/client-loader-bundle.e2e.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Real-bundle smoke: the actual tsdown client bundle of ui-layout runs - * through the loader chain (execute → handoff → factory(require) → apply → - * export re-registration). Skips when the bundle is not built (lib/client.js is a - * build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`). - */ -import { readFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' -import * as uiSlots from '@deepseek-ai/dsh-client-ui-slots' -import * as webReact from '@deepseek-ai/dsh-client-web-react' -import { createClientLoader } from '../src/client/loader/index.ts' -import type { ClientPluginHandoff } from '../src/client/loader/index.ts' -import { SessionsService } from '../src/client/sessions/service.ts' -import { SlotsService } from '../src/client/slots.ts' -import { FakeApiClient } from './fake-api.ts' - -const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout' - -type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; window?: unknown } - -afterEach(() => { - delete (globalThis as Win).DSHClientProxy - delete (globalThis as Win).window -}) - -function readLayoutBundle(): string | undefined { - try { - const require = createRequire(import.meta.url) - return readFileSync(require.resolve(`${LAYOUT_ID}/client`), 'utf8') - } catch { - return undefined - } -} - -describe('real tsdown bundle through the loader', () => { - const code = readLayoutBundle() - - it.skipIf(code === undefined)('loads ui-layout lib/client.js: handoff, DI require, apply, export surface', async () => { - // The bundle banner addresses window.DSHClientProxy; node has no window — - // alias it to globalThis so the loader-installed proxy is reachable. - ;(globalThis as Win).window = globalThis - const ctx = new Context() - // The layout apply consumes the slots + sessions services; the real chain - // loads the runtime bundle first — stand both up directly here. - ctx.plugin(SlotsService) - await ctx.fiber.await() - new SessionsService(ctx, new FakeApiClient()) - const loader = createClientLoader({ - ctx, - // The real bundle externals resolved from the seeded table. React is a - // type-only import in the layout bundle today, but jsx-runtime is real. - modules: { - 'react': await import('react'), - 'react/jsx-runtime': await import('react/jsx-runtime'), - '@deepseek-ai/dsh-client-ui-slots': uiSlots, - '@deepseek-ai/dsh-client-web-react': webReact, - }, - boot: { plugins: [{ id: LAYOUT_ID, url: `/plugins/${LAYOUT_ID}/client.js`, inject: [] }] }, - fetchBundle: () => Promise.resolve(code as string), - // node has no DOM: evaluate the bundle body directly (same synchronous - // handoff contract as the