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 e603129715..ca90cebb0e 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 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: b7081591cf7e5e3c586c74c5a71b4317376135cb -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 89557182ca7781f4fb59b8daf866aaca96cf20ee +2026-07-19-gui-layering-and-rpc-protocol.md: 7ad2a2403eb9962b369b016070e8ca378ed55c60 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 90850c469f1444e7f6cd105551e6cc21920e91d9 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 b7081591cf..7ad2a2403e 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 @@ -184,7 +184,7 @@ The remaining frame types are not re-copied here; the full unions are `MuxFrame` - **History = event replay**: one fold (client side); history pagination and live increments share one code path; the server maintains no second materialized-snapshot system. History **page boundaries align to message boundaries** (never cut mid-message; chunks group with their finalized message), and the tail page includes the in-flight partial's chunks. - **Prompt correlation**: the prompt's rpcId rides MessageSource (`'user-rpc'`) into the `user/message` event; the client uses it to promote the optimistic echo. - **Reconnect = rebuild**: no resume cursor (`mux`'s `since` signature is a reserved seat, ignored if passed); on disconnect reopen the stream + refetch history; compare `subscribed.lastSeq` with the history tail seq and backfill once if there is a seam. -- **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it). +- **Cold session handling follows ownership**: `session.history` and the source read for `session.fork` inspect persistence without an Agent, while Agent-bound ordinary-session methods such as `prompt` resume through a deduplicated in-flight table. Session-backed subagents reject that generic resume path, and attachment status is not exposed to clients (`running` already covers it). - **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only. - **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears. - **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. (`session.rename` graduated from this list: it appends a user-source `session/title` event.) 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 89557182ca..90850c469f 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 @@ -182,7 +182,7 @@ export type ResponseValue = - **历史 = 事件重放**:一套 fold(client 侧),历史分页与 live 增量同一条代码路径;server 不做物化快照第二套。history **页边界对齐消息边界**(绝不从消息中间截断;chunk 随定稿消息归组),尾页含进行中 partial 的 chunk。 - **prompt 关联**:prompt 的 rpcId 经 MessageSource(`'user-rpc'`)透传进 `user/message` 事件,client 以此把乐观回显转正。 - **重连 = 重建**:不做续传 cursor(`mux` 的 `since` 签名留座、传了忽略);断线重开流 + 重拉 history;`subscribed.lastSeq` 与 history 尾 seq 比对,有缝再补拉一次。 -- **冷 session 隐式 resume**:`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume,并发触发用在途表去重;attach 与否不对客暴露(`running` 已覆盖)。 +- **冷会话处理遵循所有权**:`session.history` 与 `session.fork` 的源端读取会在不获取 Agent 的情况下检查持久化存储,而绑定到 Agent 的普通会话方法(如 `prompt`)则通过在途表去重后恢复会话。由会话支撑的 subagent 会拒绝这条通用恢复路径,且附加状态不对客户端暴露(`running` 已经覆盖)。 - **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。 - **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。 - **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。(`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。) diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index 01334b2d8b..55efb87b8f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.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 .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md -2026-07-30-session-end-seed-log-boundary.md: ca6145f3ce404b88d2a144483c6f1145fe0a9a7e -2026-07-30-session-end-seed-log-boundary.zh.md: d492c4ac5f39328e75d991b285004e2d19c56283 +2026-07-30-session-end-seed-log-boundary.md: 9d0685876b4d1bac339961c67ab08f620e499464 +2026-07-30-session-end-seed-log-boundary.zh.md: 8fa9625ea6c58b0b07d964ef2580b670893a3d75 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index ca6145f3ce..9d0685876b 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -20,7 +20,7 @@ A bracket owner reads it positionally: an unmatched opening marker before `sessi The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating. -Two guards keep the marker precise. An omitted seed writes nothing because the session is fresh. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit. +Two guards keep the marker precise. An omitted seed writes nothing because the session is fresh. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness: each Agent-bound pickup of a cold session passes through `agentFor()`, and without the guard repeated controls would grow the log even when they perform no work. The inspection-only `session.history` and `session.fork` source paths do not create this boundary in the source. ## Persistence needs no changes diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index d492c4ac5f..8fa9625ea6 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -20,7 +20,7 @@ Status: implemented 选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。 -两条守卫让这个标记保持精确。省略种子时不写入任何内容,因为这是全新会话。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。 +两条守卫让这个标记保持精确。省略种子时不写入任何内容,因为这是全新会话。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁:每次绑定到 Agent 的冷会话接手都会经过 `agentFor()`;没有这条守卫,重复的控件操作即使没有执行任何工作,也会让日志增长。只执行检查的 `session.history` 与 `session.fork` 源端路径不会在源会话中创建这条边界。 ## 持久化无需任何改动 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml index c20d347fc9..c78049cc8d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.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 .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md -2026-07-31-web-stop-preserves-queue.md: 943e95d6951a28929c4f8ce4d0b6e17224b08ede -2026-07-31-web-stop-preserves-queue.zh.md: bbadd8adf8fd5bb0604ef87d322e48ce4c2ed759 +2026-07-31-web-stop-preserves-queue.md: b8bba45009c126495a8b03ee36d6eb2cccac9a93 +2026-07-31-web-stop-preserves-queue.zh.md: 0e1fffae8819bea81b91fae10c76dbadc78474fe diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md index 943e95d695..b8bba45009 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md @@ -12,7 +12,7 @@ The browser cannot repair that loss by resending visible rows. It does not own t ## Decision -`session.cancel` is the Web Host API's active-turn stop. It calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries. +`session.cancel` is the Web Host API's active-turn stop for ordinary sessions. It rejects session-backed subagents with `agent-busy`; otherwise it calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries. The AgentLoop starts no concurrent replacement turn. It closes and flushes the interrupted turn, reaches cancellation quiescence, and then claims the next waking queued occurrence through its existing FIFO driver. That claim emits `agent/inbox/dequeue`, so the Host's authoritative `session/queue` snapshot retires the claimed row and leaves the remaining tail visible. The browser neither resends nor promotes any row. Work that ignores cancellation delays this handoff until it settles. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md index bbadd8adf8..0e1fffae88 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md @@ -12,7 +12,7 @@ Web 停止按钮调用 `session.cancel`,后者映射到广义 `agent.cancel({ ## 决策 -`session.cancel` 是 Web Host API 的活动轮次停止操作。它调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项;Web Queue 投影继续只暴露 queued 入队项。 +`session.cancel` 是 Web Host API 面向普通会话的活动轮次停止操作。它会以 `agent-busy` 拒绝由会话支撑的 subagent;否则会调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项;Web Queue 投影继续只暴露 queued 入队项。 AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一个可唤醒的 queued 入队项。该认领会发出 `agent/inbox/dequeue`,因此 Host 的权威 `session/queue` 快照会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一交接,直到该工作结算。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 85df974af0..6c36c771ed 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: ffe248af94c08ea5dba7e47cdd1f70c56122d234 -2026-07-27-web-subagent-conversations.zh.md: 8d152cb954324c65c5662b0e2a960caf291409d1 +2026-07-27-web-subagent-conversations.md: d75bc1471815cb0da78cf078585022ca668ebb81 +2026-07-27-web-subagent-conversations.zh.md: 5cd2999cb6e8ad68bfeccd0f685dc6e4471b6091 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index ffe248af94..d75bc14718 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -6,7 +6,7 @@ English | [中文](2026-07-27-web-subagent-conversations.zh.md) ## Problem -Session-backed subagents have durable identities, persisted transcripts, and a direct-child catalog, but the Web client otherwise sees only ordinary session lineage. It cannot distinguish a subagent from a fork, discover descriptor mode, or view a cold child without using the ordinary history path that resumes an Agent. +Session-backed subagents have durable identities, persisted transcripts, and a direct-child catalog, but ordinary session lineage cannot distinguish them from forks or prove their descriptor mode and continuation authority. Generic Agent-bound Host operations can otherwise resume or drive a child outside its direct-parent continuation owner. The browser must preserve the [continuable subagent contract](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md): a continuable child has at most one process-local Activation, accepts later work only through the exact live direct parent, and uses the Agent inbox as its sole FIFO. Viewing history must not create an Activation. Once an inbox message is accepted, the HTTP caller neither owns its execution nor gains a cancellation handle. @@ -18,6 +18,8 @@ The Web product exposes the selected session's direct session-backed subagents f Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt calls `ctx.subagents.followup()` and succeeds at inbox acceptance with `{ messageId }`; it does not steer an open turn, expose an Activation, wait for completion, or return an outcome. +The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization. + The ordinary Stop action is absent from addressed child conversations. `SubagentService.followup()` owns admission only until inbox acceptance and intentionally exposes no public child cancellation operation. A later cancellation design needs an explicit authority and lifecycle contract rather than falling through to `session.cancel`. This decision covers Web discovery, transcript viewing, and parent-authorized human continuation. It does not make a subagent independently user-owned; that product remains [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md). @@ -43,7 +45,7 @@ Selecting a row records its exact address before opening the resident client `Se A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior. -Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`, because either route would activate persisted child history outside the direct-parent continuation seam. +Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`; the Host also rejects any accidental call instead of activating persisted child history outside the direct-parent continuation seam. ## Host adapter and wire contract @@ -57,6 +59,8 @@ The gateway maps missing parent, missing or diagnostic catalog entries, not-resu Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`. +The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` and `session.updateQueue` apply the same check directly because they intentionally query only attached Agents. + The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [GUI RPC layering](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). ## Client object layer and presentation @@ -75,7 +79,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence ## Alternatives considered -**Reuse ordinary session APIs.** Rejected because ordinary history may resume the child and ordinary prompt drives it without direct-parent continuation authority. +**Use ordinary session APIs for addressed children.** Rejected because generic history carries no catalog-mode verification, while Agent-bound generic controls deliberately reject subagents rather than granting direct-parent continuation authority. **Put the adapter in the webserver.** Rejected because catalog and continuation are channel-independent client capabilities; the webserver only carries validated messages. @@ -96,6 +100,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence ## Testing - Host protocol tests pin schemas, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. +- Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips, and membership refresh. - jsdom tests pin mixed-mode rows, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. - The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 8d152cb954..5cd2999cb6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -由会话支撑的 subagent 具有持久化身份、持久化 transcript(文本记录)与直接 child 目录,但 Web 客户端除此之外只能看到普通会话谱系。它无法区分 subagent 与 fork、获知描述符 mode,或在不使用会恢复 agent(智能体)的普通历史路径的情况下查看冷态 child。 +由会话支撑的 subagent 具有持久化身份、持久化 transcript(文本记录)与直接 child 目录,但普通会话谱系无法将它们与 fork 区分开,也无法证明其描述符 mode 与继续执行授权。否则,绑定到 agent(智能体)的通用 Host 操作可能在其直接 parent 继续执行 owner 之外恢复或驱动 child。 浏览器必须遵守[可继续 subagent 契约](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md):一个可继续 child 在进程内最多只能有一项 Activation,只能通过确切的存活直接 parent 接受后续工作,并将 agent inbox 用作唯一的 FIFO。查看历史不得创建 Activation。inbox 消息一经接受,HTTP 调用方既不拥有其执行过程,也不会获得取消句柄。 @@ -18,6 +18,8 @@ Web 产品通过页头操作公开选中会话中由会话支撑的直接 subage 每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词操作会调用 `ctx.subagents.followup()`,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会 steer 打开的轮次、公开 Activation、等待完成或返回结果。 +通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。 + 已寻址 child 对话不提供普通 Stop 操作。`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,并有意不公开任何 child 取消操作。后续取消设计需要显式的授权与生命周期契约,而不能回退到 `session.cancel`。 本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md)。 @@ -43,7 +45,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。 -已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`,因为任一路径都会在直接 parent 继续执行 seam 之外激活持久化 child 历史。 +已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`;Host 也会拒绝任何意外调用,而不是在直接 parent 继续执行 seam 之外激活持久化 child 历史。 ## 宿主适配器与协议契约 @@ -57,6 +59,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。 +普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 与 `session.updateQueue` 会直接执行同一检查,因为它们有意只查询已附加的 Agent。 + 适配器仍位于 `dsh-host-apiproxy`;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包(package)导入契约,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。 ## 客户端对象层与呈现 @@ -75,7 +79,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 ## 备选方案 -**复用普通会话 API。** 不予采纳,因为普通历史可能恢复 child,而普通提示词会在缺少直接 parent 继续执行授权的情况下驱动它。 +**对已寻址 child 使用普通会话 API。** 不予采纳,因为通用历史不携带目录 mode 校验,而绑定到 Agent 的通用控件会有意拒绝 subagent,不会授予直接 parent 继续执行授权。 **将适配器放入 webserver。** 不予采纳,因为目录与继续执行是通道无关的客户端功能;webserver 只承载已校验的消息。 @@ -96,6 +100,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 ## 测试 - 宿主协议测试固定 schema、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 +- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转与成员刷新。 - jsdom 测试固定混合 mode 行、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 - 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index 5fcb6367ea..99b5efd70f 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.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 .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: 57527730d0f43a3a6c7801806fb9cc136daa5f14 -2026-07-29-addressable-queue-operations.zh.md: 65617e492c16e41d19ba296c7d117f8a2771a67d +2026-07-29-addressable-queue-operations.md: 02519f8fe8be60823ac773ac4cceecb9f12f88b2 +2026-07-29-addressable-queue-operations.zh.md: 998fc49ae7289b07c97312d4814d85b5cc84387b diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index 57527730d0..02519f8fe8 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -16,7 +16,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. A synchronously re-entrant update or terminal event may reach the mirror before its outer enqueue listener; the mirror retains that unseen outcome for the current dispatch and folds it into the enqueue, so listener registration order cannot publish stale content or a ghost row. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes. -**Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. +**Queue addresses require a live ordinary-session Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A session-backed subagent returns `agent-busy` before inbox access and retains its continuation owner; for ordinary sessions, a missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. **Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend. diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index 65617e492c..998fc49ae7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -16,7 +16,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。同步可重入的 update 或终态事件可能先于外层 enqueue 监听器到达镜像;镜像会在当前分发期间保留这一尚不可见的结果,并在处理 enqueue 时把它合并进去,因此监听器注册顺序不会导致系统发布陈旧内容或不存在的行。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。 -**Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 +**Queue 寻址要求普通会话的 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。由会话支撑的 subagent 会在访问 inbox 前返回 `agent-busy`,并保留其继续执行 owner;对于普通会话,Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 **Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml index a8dde62381..e4acea8bc5 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.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 .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md -2026-07-29-durable-last-activity-index.md: 0938e7ea2aa817818ed552fd8b702795840b547a -2026-07-29-durable-last-activity-index.zh.md: 647bce817444c77ac3cd6f51385472fff745212f +2026-07-29-durable-last-activity-index.md: 0e441f54a719b29a1a450c133e08cdf7d2c82e9e +2026-07-29-durable-last-activity-index.zh.md: 9557da0a186084499dfb0f3e8d36016d5b216ae7 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md index 0938e7ea2a..0e441f54a7 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md @@ -8,7 +8,7 @@ English | [中文](2026-07-29-durable-last-activity-index.zh.md) A cold (persisted, unattached) session has no stored answer to "when was this last worked in". `dsh-host-apiproxy`'s `summarizeCold()` therefore approximates it with the log file's mtime where one exists — `locate()` resolves a per-session artifact for JSONL and `undefined` for SQLite, whose cold sessions fall back to `createdAt` — and the web client sorts its session tree by the resulting `updatedAt`. The two backends are wrong in opposite directions: JSONL reads too new, SQLite too old. -mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough. +mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. `dsh-host-apiproxy` keeps `session.history` inspection-only, but any Agent-bound ordinary-session control resumes through `agentFor()` and is enough to promote the cold artifact. The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size. diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md index 647bce8174..9557da0a18 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md @@ -8,7 +8,7 @@ Status: proposed 一个冷会话(已持久化、未附加)对「上次是什么时候在这里面工作过」没有任何已存储的答案。因此 `dsh-host-apiproxy` 的 `summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物,为 SQLite 解析出 `undefined`,而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反:JSONL 读出来偏新,SQLite 偏旧。 -mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy` 的 `agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了。 +mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。`dsh-host-apiproxy` 让 `session.history` 保持只执行检查,但任何绑定到 Agent 的普通会话控件都会通过 `agentFor()` 恢复会话,足以把冷态产物排到前面。 已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f3c1ae6e14..d83623608e 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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 packages/host/apiproxy/README.md -README.md: 15a8a6f80dc3f4c37950e85a902f5c53e83cfcac -README.zh.md: 6c61d8e2a4c44e9c1b8bbec1555c36f0cd39c1e2 +README.md: 45e79aea55665882faeee14201a2c2dda6fe9199 +README.zh.md: cb0cbe4fe53a3d328adbb57671ce6aa4a439470c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 15a8a6f80d..45e79aea55 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,16 +10,18 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md). -`session.history` pages on append-origin message boundaries: `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. +`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. +Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until an Agent-bound ordinary-session operation attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. -`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. +`session.fork` reads its source from attached state or persistence inspection without acquiring an Agent, then maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published ordinary child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace when present. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Generic Agent-bound session, command, and goal operations serve ordinary sessions only. They return `agent-busy` for a session-backed subagent instead of resuming or driving it; explicit-id `session.create` adoption and the attached-only queue controls enforce the same ownership boundary. Subagent conversation reads and continuation use the dedicated `subagent.*` domain, which retains catalog-mode and direct-parent authorization. + Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. The `session.list` summaries and `host/session-added` frames also carry the optional durable `origin: 'subagent'` classification so navigation can suppress duplicate child rows immediately and after reconnect; that bit is never continuation authority. @@ -32,7 +34,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. `command.*` addresses an ordinary session's Agent and resumes a cold ordinary session when needed, while `skill.list` resolves the project root from the session header without touching the Agent registry. `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. @@ -57,4 +59,4 @@ None; this package neither assembles nor sends a provider request. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. - **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). -- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). +- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `session.history` is inspection-only, but an Agent-bound ordinary-session operation resumes a cold session and writes the pickup boundary. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 6c61d8e2a4..cb0cbe4fe5 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -10,16 +10,18 @@ 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 -`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 +`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按追加来源的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 +会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到某项绑定到 Agent 的普通会话操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 +`session.fork` 会从已附加状态或持久化检查中读取源会话而不获取 Agent,再将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的普通子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再在源会话属于 Workspace 时加入该 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +绑定到 Agent 的通用会话、命令与目标操作只服务普通会话。对于由会话支撑的 subagent,它们会返回 `agent-busy`,而不是恢复或驱动它;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件也会执行同一所有权边界。subagent 对话读取与继续执行使用专用的 `subagent.*` 领域,该领域保留目录 mode 与直接 parent 授权。 + 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。`session.list` 摘要与 `host/session-added` 帧还会携带可选的持久化分类 `origin: 'subagent'`,使导航在实时创建与重连后都能隐藏重复的 child 行;该标记绝不是继续执行的权威依据。 @@ -32,7 +34,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。`command.*` 寻址普通会话的 Agent,并在需要时恢复冷态普通会话;`skill.list` 则从会话头解析项目根目录,不触碰 Agent 注册表。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 @@ -57,4 +59,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 - **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。 -- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 +- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`session.history` 只执行检查,但绑定到 Agent 的普通会话操作会恢复冷会话并写入这条拾起边界。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 37dd64466a..a869be700e 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -504,10 +504,10 @@ function historyPage( * registry). An absent registry means the deployment has no projection seam: * the whole block is absent and clients treat every key as capability-absent. */ -function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | undefined { +function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined { const registry = ctx.get('sessionProjections') if (registry === undefined) return undefined - return registry.snapshot(agent.session) + return registry.snapshot(session) } /** @@ -639,6 +639,13 @@ async function catalogChild( */ class SessionNotFound extends Error {} +/** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */ +class SubagentSessionOwnership extends Error { + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" is a subagent session; use subagent delivery`) + } +} + /** Requested identity already belongs to a session with another project cwd. */ class SessionCwdConflict extends Error { constructor( @@ -1008,28 +1015,70 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } + /** Whether the session's own suffix carries the durable subagent discriminator. */ + function hasSubagentDescriptor(session: Pick): boolean { + const ownStart = session.header.seedLength ?? 0 + return session.events.slice(ownStart).some(event => event.type === 'subagent/descriptor') + } + /** - * Gate the cold path on the store: an id absent from it, or naming a legacy - * log without a cwd (pre-release stance: not served, no compatibility), is - * not-found before any resume is attempted. With the gate passed, a later - * resume failure is genuinely internal. No persistence configured skips the - * gate — resume itself then fails loud with its own diagnostic. + * Generic Host interaction cannot claim a durably classified subagent or an + * Agent created through its live parent. The runtime-owner arm also covers + * descriptor-less child publication windows and older stored headers. */ - async function assertServable(sessionId: SessionId): Promise { + function hasSubagentOwner( + session: Pick, + agent: Agent | undefined, + ): boolean { + if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true + const parentId = session.header.parentSession + if (parentId === undefined || agent === undefined) return false + const parent = ctx.agents.get(parentId) + return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) + } + + /** Stable generic-Host error for an identity reserved to subagent routing. */ + function subagentOwnershipError(sessionId: SessionId): RpcError { + return { + code: 'agent-busy', + message: `session "${sessionId}" is owned by subagent routing`, + details: { reason: 'use subagent delivery for this child session' }, + } + } + + /** Inspect one cold served session without repairing, resuming, or publishing it. */ + async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) return + if (persistence === undefined) { + throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') + } const meta = (await persistence.list()).find(m => m.id === sessionId) if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) + const inspected = await persistence.inspect(sessionId) + if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) + return inspected } async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { + const attached = ctx.sessions.get(sessionId) const live = ctx.agents.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, live)) { + return { error: subagentOwnershipError(sessionId) } + } if (live !== undefined) return { agent: live } let resume = resumes.get(sessionId) if (resume === undefined) { resume = (async () => { try { - await assertServable(sessionId) + const inspected = await inspectServable(sessionId) + if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { + throw new SubagentSessionOwnership(sessionId) + } + const publishedSession = ctx.sessions.get(sessionId) + const publishedAgent = ctx.agents.get(sessionId) + if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) { + throw new SubagentSessionOwnership(sessionId) + } const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, @@ -1048,27 +1097,77 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (error instanceof SessionNotFound) { return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } } } + if (error instanceof SubagentSessionOwnership) { + return { error: subagentOwnershipError(error.sessionId) } + } // The internal details slot is contractually {}; the reason rides the message. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } } } } + type SessionReadState = { + id: SessionId + header: SessionHeader + events: SessionEvent[] + } + + /** Read one stable session prefix without acquiring an Agent owner. */ + async function readSessionState(sessionId: SessionId): Promise { + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined) { + return { + id: attached.id, + header: attached.header, + events: [...attached.events], + } + } + const inspected = await inspectServable(sessionId) + return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } + } + + /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */ + async function historyStateFor( + sessionId: SessionId, + includeProjections: boolean, + ): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> { + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined) { + const events = [...attached.events] + const projections = includeProjections ? projectionsFor(ctx, attached) : undefined + return { events, ...projections === undefined ? {} : { projections } } + } + const inspected = await inspectServable(sessionId) + const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined + return { + events: inspected.events, + ...projections === undefined ? {} : { projections }, + } + } + /** Resolve one requested identity to a live agent, creating or resuming it once. */ async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise { let creation = sessionCreations.get(sessionId) if (creation === undefined) { creation = (async () => { + const attached = ctx.sessions.get(sessionId) const live = ctx.agents.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, live)) { + throw new SubagentSessionOwnership(sessionId) + } if (live !== undefined) return live const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined const stored = persistence === undefined ? undefined : (await persistence.list()).find(header => header.id === sessionId) - if (stored !== undefined) { + if (persistence !== undefined && stored !== undefined) { if (stored.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, stored.cwd) } + const inspected = await persistence.inspect(sessionId) + if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { + throw new SubagentSessionOwnership(sessionId) + } return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, @@ -1091,7 +1190,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Another Host entry path may have published the same identity while // this operation crossed an asynchronous persistence/filesystem step. const live = ctx.agents.get(sessionId) - if (live !== undefined) return live + if (live !== undefined) { + if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId) + return live + } + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, undefined)) { + throw new SubagentSessionOwnership(sessionId) + } throw error }).finally(() => { sessionCreations.delete(sessionId) @@ -1099,6 +1205,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionCreations.set(sessionId, creation) } const agent = await creation + if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId) if (agent.session.header.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd) } @@ -1498,6 +1605,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }) } + if (error instanceof SubagentSessionOwnership) { + return err(request, subagentOwnershipError(error.sessionId)) + } return err(request, { code: 'internal', message: `failed to create session "${sessionId}": ${String(error)}`, @@ -1520,18 +1630,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - // Everything below the resume above is synchronous: the page slice, - // the seq read, and the projection walk see one un-torn session state. - const page = historyPage(ctx, found.agent.session.events, beforeSeq, maxMessages) - // Baseline rider: tail page only — loadOlder (beforeSeq present) is - // the one path that never needs a fresh projection baseline. - const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined + let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock } + try { + state = await historyStateFor(sessionId, beforeSeq === undefined) + } catch (error: unknown) { + if (error instanceof SessionNotFound) { + return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } }) + } + return err(request, { + code: 'internal', + message: `history unavailable for session "${sessionId}": ${String(error)}`, + details: {}, + }) + } + const page = historyPage(ctx, state.events, beforeSeq, maxMessages) return ok(request, { events: page.events, hasMore: page.hasMore, - ...projections === undefined ? {} : { projections }, + ...state.projections === undefined ? {} : { projections: state.projections }, }) }, @@ -1606,9 +1722,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async fork(request) { const { sessionId, atSeq } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const source = found.agent.session + let source: SessionReadState + try { + source = await readSessionState(sessionId) + } catch (error: unknown) { + if (error instanceof SessionNotFound) { + return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } }) + } + return err(request, { + code: 'internal', + message: `fork source unavailable for session "${sessionId}": ${String(error)}`, + details: {}, + }) + } const events = source.events // An in-log anchor belongs to the turn containing it and must never // clip backward to an earlier completed turn. Omitted and past-end @@ -1694,6 +1820,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro updateQueue(request) { const { sessionId, itemId, action } = request.payload const agent = ctx.agents.get(sessionId) + if (agent !== undefined && hasSubagentOwner(agent.session, agent)) { + return Promise.resolve(err(request, subagentOwnershipError(sessionId))) + } if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') { return Promise.resolve(err(request, { code: 'queue-item-not-found', @@ -1714,6 +1843,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { sessionId }, })) } + if (hasSubagentOwner(agent.session, agent)) { + return Promise.resolve(err(request, subagentOwnershipError(sessionId))) + } agent.cancel({ kind: 'user' }, { keepInbox: true }) return Promise.resolve(ok(request, { accepted: true as const })) }, diff --git a/packages/host/apiproxy/src/api/commands.ts b/packages/host/apiproxy/src/api/commands.ts index 994d9196d4..984a184093 100644 --- a/packages/host/apiproxy/src/api/commands.ts +++ b/packages/host/apiproxy/src/api/commands.ts @@ -1,8 +1,8 @@ /** * commands domain contract: the web catalog/dispatch face of the host command - * registry (`ctx.commands`). Both methods address one session's agent via - * `sessionId` — every served session has an Agent (Session+Agent are born - * together), so there is no agent-less surface on this wire. + * registry (`ctx.commands`). Both methods address an ordinary session's Agent + * via `sessionId`, resuming it when cold. Session-backed subagents reject with + * `agent-busy` and retain their dedicated continuation owner. */ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' @@ -27,7 +27,8 @@ export interface CommandDescriptor { export interface CommandsApi { /** * Lists the addressed agent's effective command catalog (name-sorted, - * globals plus its scoped shadows). + * globals plus its scoped shadows). Session-backed subagents reject with + * `agent-busy`. */ list(request: RpcRequest<{ sessionId: SessionId }>): Promise> @@ -42,6 +43,7 @@ export interface CommandsApi { * pairing id, letting the issuing client correlate this acknowledgment * with that flow node. The signal rides beside the request, never on the * wire: the fetch carrier's request signal cancels the running handler. + * Session-backed subagents reject with `agent-busy` before dispatch. */ execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal): Promise> diff --git a/packages/host/apiproxy/src/api/goals.ts b/packages/host/apiproxy/src/api/goals.ts index c585eacdf5..9ed65e9461 100644 --- a/packages/host/apiproxy/src/api/goals.ts +++ b/packages/host/apiproxy/src/api/goals.ts @@ -22,7 +22,11 @@ export interface GoalRef { readonly revision: number } -/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */ +/** + * Goal-domain unary methods. Every mutation resolves an ordinary session's + * Agent and applies one CAS-guarded verb; session-backed subagents reject with + * `agent-busy`. + */ export interface GoalsApi { /** Create and arm a goal. */ create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>): diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 3d80f4f15d..78643ed343 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -219,17 +219,22 @@ export interface SessionsApi { * the client needs a fresh baseline already pulls the tail page, and * loadOlder (the only beforeSeq path) is the only path that never needs one. * A deployment without the registry serves histories without the block. + * Reading history uses an attached Session or persistence inspection and + * never resumes or publishes an Agent. */ history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): Promise> - /** Reads a fresh advisory model directory for this session. Provider lookups run independently. */ + /** + * Reads a fresh advisory model directory for an ordinary session. Provider + * lookups run independently; subagents reject with `agent-busy`. + */ models(request: RpcRequest<{ sessionId: SessionId }>): Promise> /** * Selects the complete target for this session. Exact model metadata * validates an optional reasoning effort, while catalog membership remains - * advisory. + * advisory. Session-backed subagents reject with `agent-busy`. */ selectModel(request: RpcRequest<{ sessionId: SessionId @@ -245,6 +250,7 @@ export interface SessionsApi { * normalized accepted title and the title event's seq return so the caller * can settle its projection cell without waiting for the push frame. A * title that normalizes to empty fails with `title-invalid`. + * Session-backed subagents reject with `agent-busy`. */ rename(request: RpcRequest<{ sessionId: SessionId; title: string }>): Promise> @@ -265,23 +271,30 @@ export interface SessionsApi { * falls back to the source's last completed turn. An in-log anchor whose * turn is still open fails with `fork-unavailable` instead of clipping to * an earlier turn. The child inherits the source cwd, latest logged model - * target, workspace attachment, and `parentSessionId` lineage; the seed - * prefix carries the source title. + * target and `parentSessionId` lineage; the seed prefix carries the source + * title. Reading the source uses attached state or persistence inspection + * without acquiring an Agent. Workspace attachment follows the source + * directly when it belongs to one. */ fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>): Promise> - /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */ + /** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */ prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): Promise> /** - * Edits or removes one pending queued occurrence. + * Edits or removes one pending queued occurrence on an ordinary session. + * Session-backed subagents reject with `agent-busy`. */ updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>): Promise> - /** Stops the active turn, preserving pending inbox work that resumes in FIFO order after cancellation settles. */ + /** + * Stops an ordinary session's active turn, preserving pending inbox work + * that resumes in FIFO order after cancellation settles. Session-backed + * subagents reject with `agent-busy`. + */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> } diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 108cb72e25..1e5b095778 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -1,21 +1,19 @@ /** * Cold-session and degenerate-composition paths of the host ApiProxy: - * sessions.list merging persisted-but-unattached summaries (mtime source, - * createdAt fallbacks, lineage projection), the resume error split when - * the composition has no persistence gate and no agent factory, and the - * agent-busy mapping of a synchronous prompt rejection. + * metadata-only listing, Agent-free history reads, subagent ownership + * isolation, and prompt failure mapping. */ import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SessionStore from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' @@ -115,8 +113,160 @@ describe('attached updatedAt excludes end-seed', () => { }) }) +describe('subagent ownership fence', () => { + it('reads a cold child without an Agent and rejects generic resume or adoption', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('session-child') + const meta = header('session-child', 1000, { + parentSession: sid('session-parent'), + seedLength: 0, + }) + const events = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'user/message', + seq: 1, + time: 2, + data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, + { + type: 'subagent/descriptor', + seq: 2, + time: 3, + data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' }, + }, + { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + const inspect = vi.fn(() => Promise.resolve({ meta, events })) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect, + locate: () => undefined, + } as never) + const resume = vi.spyOn(ctx.agents, 'resume') + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const history = await api.sessions.history(request({ sessionId })) + expect(history.result.ok).toBe(true) + if (history.result.ok) { + expect(history.result.value.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type)) + } + expect(ctx.agents.get(sessionId)).toBeUndefined() + + const prompt = await api.sessions.prompt(request({ + sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'follow up' }], + })) + expect(prompt.result.ok).toBe(false) + if (!prompt.result.ok) { + expect(prompt.result.error).toMatchObject({ + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }) + } + + const create = await api.sessions.create(request({ sessionId, cwd: '/proj' })) + expect(create.result.ok).toBe(false) + if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy') + expect(resume).not.toHaveBeenCalled() + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(inspect).toHaveBeenCalledTimes(3) + }) + + it('rejects origin-marked and runtime-owned live children from generic controls', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } }) + const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent + ctx.agents.register(parent) + + const originSession = ctx.sessions.create(sid('session-origin-child'), { + meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, + }) + const cancel = vi.fn() + const updateInbox = vi.fn(() => 'applied' as const) + const originChild = { + id: originSession.id, + session: originSession, + status: 'idle', + ctx, + cancel, + updateInbox, + } as unknown as Agent + ctx.agents.register(originChild) + + const startingSession = ctx.sessions.create(sid('session-starting-child'), { + meta: { cwd: '/proj', parentSession: parent.id }, + }) + const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent + ctx.agents.enter(startingChild, parent) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) + expect(stopped.result.ok).toBe(false) + if (!stopped.result.ok) expect(stopped.result.error.code).toBe('agent-busy') + expect(cancel).not.toHaveBeenCalled() + + const queued = await api.sessions.updateQueue(request({ + sessionId: originChild.id, + itemId: InboxItemId('queued-item'), + action: { kind: 'remove' }, + })) + expect(queued.result.ok).toBe(false) + if (!queued.result.ok) expect(queued.result.error.code).toBe('agent-busy') + expect(updateInbox).not.toHaveBeenCalled() + + const models = await api.sessions.models(request({ sessionId: startingChild.id })) + expect(models.result.ok).toBe(false) + if (!models.result.ok) expect(models.result.error.code).toBe('agent-busy') + + const create = await api.sessions.create(request({ sessionId: originChild.id, cwd: '/proj' })) + expect(create.result.ok).toBe(false) + if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy') + + const history = await api.sessions.history(request({ sessionId: originChild.id })) + expect(history.result.ok).toBe(true) + expect(ctx.agents.get(originChild.id)).toBe(originChild) + }) + + it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const session = ctx.sessions.create(sid('session-ordinary-fork'), { + seed: [{ + type: 'subagent/descriptor', + seq: 0, + time: 1, + data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' }, + }], + meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 }, + }) + const followup = vi.fn() + const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + ctx.agents.register(agent) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const response = await api.sessions.prompt(request({ + sessionId: agent.id, + mode: 'queue', + content: [{ type: 'text', text: 'ordinary work' }], + })) + expect(response.result.ok).toBe(true) + expect(followup).toHaveBeenCalledOnce() + }) +}) + describe('degenerate composition (no persistence, no factory)', () => { - it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => { + it('list skips the cold merge and history reports missing persistence as internal', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) @@ -127,15 +277,32 @@ describe('degenerate composition (no persistence, no factory)', () => { expect(listed.result.ok).toBe(true) if (listed.result.ok) expect(listed.result.value.items).toEqual([]) - // No persistence → the servable gate passes silently; the factory-less - // registry then rejects resume, which is NOT a SessionNotFound. + // No persistence means cold history cannot inspect a transcript. const response = await api.sessions.history(request({ sessionId: sid('session-ghost') })) expect(response.result.ok).toBe(false) if (!response.result.ok) { expect(response.result.error.code).toBe('internal') - expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/) + expect(response.result.error.message).toMatch(/history unavailable for session "session-ghost"/) } }) + + it('maps a persistence catalog miss to session-not-found without inspection', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const inspect = vi.fn() + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([]), + inspect, + } as never) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') + expect(inspect).not.toHaveBeenCalled() + }) }) describe('sessions.prompt synchronous rejection', () => { diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 6df71258be..f2e9e352d7 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -99,6 +99,17 @@ describe('command.list', () => { expect(error.code).toBe('internal') expect(error.message).toContain('command registry') }) + + it('does not route a live subagent through the generic command domain', async () => { + const ctx = await harness() + const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj', origin: 'subagent' } }) + const agent = { id: session.id, session, status: 'idle', ctx } as Agent + ctx.agents.register(agent) + const api = createApiProxy(ctx, DEFAULTS) + + const error = expectErr(await api.commands.list(request({ sessionId: agent.id }))) + expect(error).toMatchObject({ code: 'agent-busy' }) + }) }) describe('command.execute', () => { @@ -142,7 +153,7 @@ describe('command.execute', () => { const api = createApiProxy(ctx, DEFAULTS) const missing = expectErr(await api.commands.execute( request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal)) - expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate + expect(missing.code).toBe('internal') // Cold Agent-bound access fails loud when persistence is absent. const bare = await harness({ commands: false }) const bareApi = createApiProxy(bare, DEFAULTS) diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 57f854c1ea..d0226d81e6 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -1,13 +1,13 @@ /** Session-fork boundaries, lineage, and inherited model routing. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' -import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -94,6 +94,49 @@ describe('sessions.fork', () => { await ctx.fiber.dispose() }) + it('forks a persisted subagent without resuming its Agent', async () => { + const ctx = await composed() + const sourceId = sid('session-cold-subagent') + const parentId = sid('session-cold-parent') + const header: SessionHeader = { + version: 0, + id: sourceId, + createdAt: 1, + cwd: '/proj', + parentSession: parentId, + origin: 'subagent', + } + const events = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'user/message', + seq: 1, + time: 2, + data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + { type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([header]), + inspect: () => Promise.resolve({ meta: header, events }), + } as never) + const resume = vi.spyOn(ctx.agents, 'resume') + + const response = await api(ctx).sessions.fork(request({ sessionId: sourceId })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) return + expect(resume).not.toHaveBeenCalled() + expect(ctx.agents.get(sourceId)).toBeUndefined() + expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({ + parentSession: sourceId, + cwd: '/proj', + }) + expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined() + await ctx.fiber.dispose() + }) + it('uses the last completed turn only for omitted and past-end anchors', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-tail', 2, 'open') diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index bcfc067ff5..14526afefb 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -1,20 +1,16 @@ /** - * Projection carrier paths of the host ApiProxy: the history tail page's - * projections block reads the registry's watermark snapshot (asOfSeq = last - * event seq, one consistent cut); loadOlder pages never carry the block; a - * composition without the registry serves histories without it; a disposed - * registration's key leaves subsequent responses; and every unit change is - * pushed to mux consumers as a session/projection frame minted here. + * Projection carrier paths of the host ApiProxy: history tail pages snapshot + * attached state or fold one cold inspected prefix, loadOlder omits the block, + * and live unit changes push session/projection frames. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { z } from 'zod' import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -53,9 +49,6 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: await ctx.plugin(AgentRegistry) if (withRegistry) await ctx.plugin(SessionProjectionRegistry) const session = ctx.sessions.create() - // history resolves the agent first; a live structural stub is enough (only - // .session is read on this path). - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) return { ctx, session } } @@ -87,6 +80,40 @@ describe('session.history projections block', () => { expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq) }) + it('folds a cold inspected prefix without publishing an Agent', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(lastUserUnit()) + const sessionId = SessionId('session-cold-history') + const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/tmp' } + const events = [{ + type: 'user/message', + seq: 0, + time: 2, + data: createUserMessage({ + content: [{ type: 'text', text: 'persisted' }], + source: { kind: 'user' }, + }), + surfaceOp: 'append', + }] as SessionEvent[] + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + } as never) + + const response = await api(ctx).sessions.history(request({ sessionId })) + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.projections).toEqual({ + asOfSeq: 0, + values: { 'test/last-user': { text: 'persisted' } }, + }) + expect(ctx.agents.get(sessionId)).toBeUndefined() + }) + it('never carries the block on loadOlder pages (beforeSeq present)', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit())