Merge branch 'claude/web-llm-pi-ai-config-385e24' into claude/pi-ai-model-discovery
# Conflicts: # docs/cordis-catalog/events.md # docs/core-data-structures/core.i18n.yaml # docs/event-producer-consumer.md # packages/host/apiproxy/README.i18n.yaml # packages/llm/llm/README.i18n.yaml
This commit is contained in:
@@ -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: dd73fca76d60a27a8f2e764cfec6c64612ad41a9
|
||||
README.zh.md: febda99a17beeaef49d5af410afd58bc8e9481c6
|
||||
README.md: 00b9ea075af7eb55041d48ceb3a1363e9de90397
|
||||
README.zh.md: e62e3fa99c2908afeca85b7182701f6fcdf19de9
|
||||
|
||||
@@ -12,21 +12,19 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
|
||||
|
||||
Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`.
|
||||
|
||||
`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` 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` and `assistant/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 use the generic projection pair: the history-tail `projections` block plus `session/projection` frames under the `title` key. 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), delegates to `ctx.sessionTitle.rename`, and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame. The accepted `session/title` event pins the title against automatic regeneration; 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 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.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, or the nearest workspace-owning ancestor when the source is a subagent. 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` 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 model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `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 conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `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 message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
|
||||
|
||||
Pending inbox input is a live control-plane contract, not session history. The gateway mirrors `InboxItem` occurrences from `agent/inbox/*` with their `queued` or `steering` placement and broadcasts authoritative `session/queue` snapshots on every change and reconnect. A steering occurrence remains in this projection until the corresponding durable `steering/message` has been published, preserving the Host's linear event order during the handoff. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and strict steer transfers its complete message into the current next-step window. A closed window returns `steer-unavailable` without changing the row. `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 ordinary-session 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.
|
||||
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()`.
|
||||
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
|
||||
@@ -36,12 +34,10 @@ 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 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. `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 `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 `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, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `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. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. 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 and native actions included (`settings.describe`/`openDocument`/`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.
|
||||
|
||||
The `subagent.*` domain addresses direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the complete durable one-shot and continuable catalog from `ctx.subagents.listChildren`, including each healthy row's origin-classified `hasChildren` hint, replaces corpus activity with the exact child Agent driver's running state, and includes an exact-live-parent hint; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` accepts only continuable addresses, requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
|
||||
@@ -61,4 +57,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. `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).
|
||||
- **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).
|
||||
|
||||
@@ -12,21 +12,19 @@
|
||||
|
||||
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
|
||||
|
||||
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按由追加事件产生的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/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.list`;冷会话在其中仍只有元数据,直到某项绑定到 Agent 的普通会话操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`,并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前结算自己的 `title` 投影格。被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖;规范化后为空的标题返回 `title-invalid`。
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
`session.fork` 会从已附加状态或持久化检查中读取源会话而不获取 Agent,再将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的普通子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace;若源会话是 subagent,则改为附加到最近拥有 Workspace 的祖先。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。
|
||||
`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.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
|
||||
绑定到 Agent 的通用会话、命令与目标操作只服务普通会话。对于由会话支撑的 subagent,它们会返回 `agent-busy`,而不是恢复或驱动它;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件也会执行同一所有权边界。subagent 对话读取与继续执行使用专用的 `subagent.*` 领域,该领域保留目录 mode 与直接 parent 授权。
|
||||
待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
|
||||
待处理的 inbox 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 `InboxItem` 入队项及其 `queued` 或 `steering` placement,并在每次变更和重连时广播权威的 `session/queue` 快照。steering(中途引导)入队项会一直保留在该投影中,直到对应的持久 `steering/message` 已发布,从而在交接期间保持 Host 的线性事件顺序。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,严格 steering 会把其完整消息转移到当前 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不改变该行。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的普通会话 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或 dispose(资源释放)后存活。客户端绝不根据轮次或状态事件推断项已退役。
|
||||
|
||||
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 行;该标记绝不是继续执行的权威依据。
|
||||
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.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
|
||||
@@ -36,12 +34,10 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 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` 而不是做差分。
|
||||
`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` 而不是做差分。
|
||||
|
||||
`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`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`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` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list` 从 `ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,并把语料活动状态替换为确切 child Agent driver 的运行状态,同时提供确切 parent 是否存活的提示;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 只接受可继续地址,要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
|
||||
@@ -61,4 +57,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,而每一次持久写入都会刷新它,包括这条边界。`session.history` 只执行检查,但绑定到 Agent 的普通会话操作会恢复冷会话并写入这条拾起边界。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.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)。
|
||||
|
||||
@@ -8,14 +8,12 @@ import { mkdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, InboxItemId,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -30,7 +28,7 @@ import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
@@ -85,7 +83,7 @@ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
|
||||
const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
|
||||
/** Conversation message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
||||
|
||||
/** Product settings intentionally exposed beside model-provider namespaces. */
|
||||
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding'])
|
||||
@@ -762,128 +760,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Per-session pending-occurrence mirror serving live and mux-open
|
||||
* `session/queue` snapshots. It carries both queued and steering placements.
|
||||
* Each terminal inbox event retires one matching occurrence, so repeated
|
||||
* sends of the same identified message remain visible until every occurrence
|
||||
* is claimed.
|
||||
*/
|
||||
const queuedMirror = new Map<SessionId, InboxItem[]>()
|
||||
type UnseenQueueEvent =
|
||||
| { readonly kind: 'update'; readonly item: InboxItem }
|
||||
| { readonly kind: 'terminal' }
|
||||
const unseenQueueEvents = new Map<SessionId, Map<InboxItemId, UnseenQueueEvent>>()
|
||||
const rememberUnseen = (sessionId: SessionId, itemId: InboxItemId, event: UnseenQueueEvent): void => {
|
||||
let events = unseenQueueEvents.get(sessionId)
|
||||
if (events === undefined) {
|
||||
events = new Map()
|
||||
unseenQueueEvents.set(sessionId, events)
|
||||
/** Project both durable inbox lists, optionally including the splice currently being emitted. */
|
||||
const queueItems = (
|
||||
agent: Agent,
|
||||
splice?: SessionEventMap['agent/inbox/spliced'],
|
||||
): QueuedInboxItem[] => {
|
||||
const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
|
||||
const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
|
||||
return splice?.target === target
|
||||
? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
|
||||
: messages
|
||||
}
|
||||
events.set(itemId, event)
|
||||
// Only synchronous re-entrancy may deliver a mutation before its outer
|
||||
// enqueue observer. Drop unmatched protocol-invalid observations instead
|
||||
// of retaining process-local ids indefinitely.
|
||||
queueMicrotask(() => {
|
||||
const current = unseenQueueEvents.get(sessionId)
|
||||
if (current?.get(itemId) !== event) return
|
||||
current.delete(itemId)
|
||||
if (current.size === 0) unseenQueueEvents.delete(sessionId)
|
||||
})
|
||||
}
|
||||
const takeUnseen = (sessionId: SessionId, itemId: InboxItemId): UnseenQueueEvent | undefined => {
|
||||
const events = unseenQueueEvents.get(sessionId)
|
||||
const event = events?.get(itemId)
|
||||
if (event === undefined) return undefined
|
||||
events?.delete(itemId)
|
||||
if (events?.size === 0) unseenQueueEvents.delete(sessionId)
|
||||
return event
|
||||
}
|
||||
const publishQueue = (sessionId: SessionId): void => {
|
||||
const items = queuedMirror.get(sessionId) ?? []
|
||||
broadcast({
|
||||
type: 'session/queue',
|
||||
sessionId,
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
placement: item.placement,
|
||||
message: item.message,
|
||||
return [
|
||||
...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })),
|
||||
...project('next-step').map(message => ({
|
||||
id: message.id,
|
||||
// Only user-origin messages are steering; injected context (approval
|
||||
// notices, task completion, attached snapshots) is not a user action
|
||||
// and must not render as a pending steering bubble.
|
||||
placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
|
||||
message,
|
||||
})),
|
||||
})
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const retireKnown = (sessionId: SessionId, itemId: InboxItemId): boolean => {
|
||||
const entries = queuedMirror.get(sessionId)
|
||||
if (entries === undefined) return false
|
||||
const index = entries.findIndex(entry => entry.id === itemId)
|
||||
if (index === -1) return false
|
||||
entries.splice(index, 1)
|
||||
if (entries.length === 0) queuedMirror.delete(sessionId)
|
||||
return true
|
||||
}
|
||||
const retire = (agent: Agent, item: InboxItem): boolean => {
|
||||
if (retireKnown(agent.id, item.id)) return true
|
||||
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
|
||||
return false
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
|
||||
const unseen = takeUnseen(agent.id, item.id)
|
||||
if (unseen?.kind === 'terminal') return
|
||||
let entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) {
|
||||
entries = []
|
||||
queuedMirror.set(agent.id, entries)
|
||||
}
|
||||
entries.push(unseen?.kind === 'update' ? unseen.item : item)
|
||||
publishQueue(agent.id)
|
||||
}),
|
||||
ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) {
|
||||
rememberUnseen(agent.id, item.id, { kind: 'update', item })
|
||||
return
|
||||
}
|
||||
const index = entries.findIndex(entry => entry.id === item.id)
|
||||
if (index === -1) {
|
||||
rememberUnseen(agent.id, item.id, { kind: 'update', item })
|
||||
return
|
||||
}
|
||||
entries.splice(index, 1, item)
|
||||
publishQueue(agent.id)
|
||||
}),
|
||||
ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => {
|
||||
if (item.placement === 'steering') {
|
||||
// AgentLoop appends the durable steering/message synchronously after
|
||||
// this claim. Retain and retire the mirror row in the following
|
||||
// microtask so any re-entrant snapshot and the Host's linear mux
|
||||
// stream keep it visible until the durable event exists.
|
||||
const present = queuedMirror.get(agent.id)?.some(entry => entry.id === item.id) === true
|
||||
if (!present) {
|
||||
retire(agent, item)
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (retireKnown(agent.id, item.id)) publishQueue(agent.id)
|
||||
})
|
||||
} else if (retire(agent, item)) {
|
||||
// Queued claims have no durable same-message handoff to order.
|
||||
// Publish retirement synchronously as before.
|
||||
publishQueue(agent.id)
|
||||
}
|
||||
}),
|
||||
ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => {
|
||||
let changed = false
|
||||
for (const item of items) changed = retire(agent, item) || changed
|
||||
if (changed) publishQueue(agent.id)
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
queuedMirror.delete(session.id)
|
||||
unseenQueueEvents.delete(session.id)
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'api-proxy: queued mirror')
|
||||
}
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'agent/inbox/spliced') return
|
||||
const agent = ctx.agents.get(session.id)
|
||||
if (agent?.session !== session) return
|
||||
broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) })
|
||||
})
|
||||
|
||||
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
|
||||
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
|
||||
@@ -1583,7 +1489,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
page = await sessionQuery.searchSessions({
|
||||
query: request.payload.query,
|
||||
eventFilters: [
|
||||
{ kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] },
|
||||
{ kind: 'type', values: ['user/message', 'assistant/message'] },
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: requestedPageLimit,
|
||||
@@ -1935,21 +1841,33 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: { itemId },
|
||||
}))
|
||||
}
|
||||
const result = agent.updateInbox(itemId, action)
|
||||
if (result === 'not-found') {
|
||||
const target = agent.inbox.nextTurn.some(message => message.id === itemId)
|
||||
? 'next-turn'
|
||||
: agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined
|
||||
const message = target === undefined
|
||||
? undefined
|
||||
: (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep)
|
||||
.find(candidate => candidate.id === itemId)
|
||||
if (target === undefined || message === undefined) {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'queue-item-not-found',
|
||||
message: 'queued item is no longer pending',
|
||||
details: { itemId },
|
||||
}))
|
||||
}
|
||||
if (result === 'steer-unavailable') {
|
||||
if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'steer-unavailable',
|
||||
message: 'current turn no longer accepts steering',
|
||||
details: { itemId },
|
||||
}))
|
||||
}
|
||||
if (action.kind === 'edit') {
|
||||
agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content }))
|
||||
} else {
|
||||
agent.inbox.remove(itemId)
|
||||
if (action.kind === 'steer') agent.steer(message)
|
||||
}
|
||||
return Promise.resolve(ok(request, { accepted: true as const }))
|
||||
},
|
||||
|
||||
@@ -2636,16 +2554,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
|
||||
// in arrival order per session; a reconnecting client rebuilds its
|
||||
// queue view from these alone.
|
||||
for (const [sessionId, items] of queuedMirror) {
|
||||
queue.push(frame({
|
||||
type: 'session/queue',
|
||||
sessionId,
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
placement: item.placement,
|
||||
message: item.message,
|
||||
})),
|
||||
}))
|
||||
for (const session of ctx.sessions.list()) {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
if (agent?.session === session && agent.inbox.hasPending) {
|
||||
queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
|
||||
}
|
||||
}
|
||||
// Per-session open-call table for result-view pairing. Bounded by the
|
||||
// per-turn call count: entries clear on turn/end; a table miss (stream
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, inboxItemIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
@@ -53,8 +53,8 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
type: z.literal('session/queue'),
|
||||
sessionId: sessionIdSchema,
|
||||
items: z.array(z.object({
|
||||
id: inboxItemIdSchema,
|
||||
placement: z.union([z.literal('queued'), z.literal('steering')]),
|
||||
id: messageIdSchema,
|
||||
placement: z.union([z.literal('queued'), z.literal('steering'), z.literal('context')]),
|
||||
message: messageSchema,
|
||||
})),
|
||||
}),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
|
||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
@@ -34,10 +34,10 @@ export type ToolEventView =
|
||||
|
||||
/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedInboxItem {
|
||||
/** Agent-owned occurrence identity; queue mutations address only `queued` items. */
|
||||
id: InboxItemId
|
||||
/** Agent-resolved FIFO placement; clients render queued and steering items on different surfaces. */
|
||||
placement: 'queued' | 'steering'
|
||||
/** Message identity used by inbox mutations. */
|
||||
id: MessageId
|
||||
/** Agent-resolved FIFO placement; queued and steering items render on different surfaces, context items stay invisible until claimed. */
|
||||
placement: 'queued' | 'steering' | 'context'
|
||||
/** Complete pending message; it is not durable until the Agent claims it. */
|
||||
message: Message
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ export type {
|
||||
// ---- Errors and ids ----
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
|
||||
// ---- Fixed session-search product bounds ----
|
||||
export {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import type { z as zCore } from 'zod'
|
||||
type ZodIssue = zCore.core.$ZodIssue
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
|
||||
/**
|
||||
* Message correlation id: the initiator mints it on a request; a response
|
||||
@@ -45,8 +45,8 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-create-failed': { path: string }
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-busy': { reason: string }
|
||||
'queue-item-not-found': { itemId: InboxItemId }
|
||||
'steer-unavailable': { itemId: InboxItemId }
|
||||
'queue-item-not-found': { itemId: MessageId }
|
||||
'steer-unavailable': { itemId: MessageId }
|
||||
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
||||
'command-error': {}
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type {
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
|
||||
/** InboxItemId: one brand cast after non-empty string validation. */
|
||||
export const inboxItemIdSchema = z.string().min(1) as unknown as z.ZodType<InboxItemId>
|
||||
/** MessageId: one brand cast after non-empty string validation. */
|
||||
export const messageIdSchema = z.string().min(1) as unknown as z.ZodType<MessageId>
|
||||
|
||||
/**
|
||||
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
|
||||
@@ -264,7 +264,7 @@ export const sessionPromptValueSchema = z.object({
|
||||
/** session.updateQueue request payload. */
|
||||
export const sessionUpdateQueueRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
itemId: inboxItemIdSchema,
|
||||
itemId: messageIdSchema,
|
||||
action: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }),
|
||||
z.object({ kind: z.literal('remove') }),
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* else references RequestPayload<'session.*'> / ResponseValue<'session.*'>.
|
||||
*/
|
||||
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// The pure-type outlet: api/ is browser-importable, and the package root's
|
||||
// cordis Context merge (via dsh-agent) must not enter client aggregates.
|
||||
@@ -287,7 +287,7 @@ export interface SessionsApi {
|
||||
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
|
||||
* Session-backed subagents reject with `agent-busy`.
|
||||
*/
|
||||
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
|
||||
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: MessageId; action: QueueAction }>):
|
||||
Promise<RpcResponse<{ accepted: true }>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
/** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */
|
||||
function agentOf(ctx: Context): Agent {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
return { session } as unknown as Agent
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('approval pending registry', () => {
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' })
|
||||
const agent = { session } as unknown as Agent
|
||||
const cancelled = new AbortController()
|
||||
@@ -308,7 +308,7 @@ describe('approval pending registry', () => {
|
||||
// Bypass ApprovalService: a log whose sole asked event already has its
|
||||
// decided partner must not be re-claimed — the answerer delegates.
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' })
|
||||
session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' })
|
||||
const agent = { session } as unknown as Agent
|
||||
@@ -322,7 +322,7 @@ describe('approval pending registry', () => {
|
||||
// Bypass ApprovalService: dispatch the waterfall directly with a session
|
||||
// that has no approval/asked event — the proxy answerer must call next().
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = { session } as unknown as Agent
|
||||
const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const))
|
||||
expect(outcome).toBe('unavailable')
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('summary blank = conversation not started', () => {
|
||||
const session = ctx.sessions.create()
|
||||
attach(session)
|
||||
appendStandalone(session)
|
||||
session.append('turn/start', { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 0 })
|
||||
expect(await listBlank(api, session.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,8 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -89,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
const worked = 1_000_000
|
||||
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
],
|
||||
meta: { cwd: '/proj', createdAt: 500 },
|
||||
@@ -105,7 +106,7 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
expect(summary?.updatedAt).toBe(worked)
|
||||
|
||||
// Real work appended after end-seed does move it.
|
||||
resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
resumed.append('turn/start', { turn: 2 })
|
||||
const after = await api.sessions.list(request({}))
|
||||
if (!after.result.ok) throw new Error('list failed')
|
||||
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
@@ -216,7 +217,7 @@ describe('subagent ownership fence', () => {
|
||||
|
||||
const queued = await api.sessions.updateQueue(request({
|
||||
sessionId: originChild.id,
|
||||
itemId: InboxItemId('queued-item'),
|
||||
itemId: MessageId('queued-item'),
|
||||
action: { kind: 'remove' },
|
||||
}))
|
||||
expect(queued.result.ok).toBe(false)
|
||||
|
||||
@@ -11,8 +11,8 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -20,7 +20,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import type { HostFrame, MuxFrame } from '../src/api/index.ts'
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
@@ -63,7 +63,14 @@ async function harness(options: { commands?: boolean; skills?: boolean } = {}):
|
||||
/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
|
||||
function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
|
||||
const session = ctx.sessions.create(sessionId)
|
||||
const agent = { id: session.id, session, status: 'idle', ctx } as Agent
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
const agent = {
|
||||
id: session.id,
|
||||
session,
|
||||
inbox,
|
||||
status: 'idle',
|
||||
ctx,
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
@@ -78,6 +85,13 @@ async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number,
|
||||
return frames
|
||||
}
|
||||
|
||||
/** Read the next payload from an open stream. */
|
||||
async function nextFrame<F>(iterator: AsyncIterator<RpcRequest<F>>): Promise<F> {
|
||||
const result = await iterator.next()
|
||||
if (result.done) throw new Error('stream ended')
|
||||
return result.value.payload
|
||||
}
|
||||
|
||||
describe('command.list', () => {
|
||||
it('serves the addressed agent\'s name-sorted catalog', async () => {
|
||||
const ctx = await harness()
|
||||
@@ -99,17 +113,6 @@ 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', () => {
|
||||
@@ -153,7 +156,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') // Cold Agent-bound access fails loud when persistence is absent.
|
||||
expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
|
||||
|
||||
const bare = await harness({ commands: false })
|
||||
const bareApi = createApiProxy(bare, DEFAULTS)
|
||||
@@ -275,7 +278,7 @@ describe('host/commands-changed frame', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** Build one frozen inbox message for the live `agent/inbox/*` events. */
|
||||
/** Build one frozen inbox message. */
|
||||
function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
|
||||
return freezeMessage({
|
||||
id: MessageId(id),
|
||||
@@ -285,28 +288,19 @@ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
|
||||
})
|
||||
}
|
||||
|
||||
/** Build one addressable inbox occurrence around a frozen message. */
|
||||
function inboxItem(id: string, message: UserMessage, placement: InboxPlacement): InboxItem {
|
||||
return { id: InboxItemId(id), message, placement }
|
||||
}
|
||||
|
||||
describe('session.updateQueue', () => {
|
||||
it('routes addressable actions and reports strict steer races', async () => {
|
||||
it('splices a queued message and reports a lost claim race', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = stubAgent(ctx)
|
||||
const seen: unknown[] = []
|
||||
agent.updateInbox = (id, action) => {
|
||||
seen.push({ id, action })
|
||||
if (id === InboxItemId('present')) return 'applied'
|
||||
return id === InboxItemId('closed') ? 'steer-unavailable' : 'not-found'
|
||||
}
|
||||
const present = inboxMessage('present', 'before')
|
||||
agent.inbox.splice('next-turn', 0, 0, [present])
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const applied = await api.sessions.updateQueue({
|
||||
rpcId: RpcId('q-apply'),
|
||||
payload: {
|
||||
sessionId: agent.id,
|
||||
itemId: InboxItemId('present'),
|
||||
itemId: MessageId('present'),
|
||||
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
|
||||
},
|
||||
})
|
||||
@@ -315,28 +309,15 @@ describe('session.updateQueue', () => {
|
||||
rpcId: RpcId('q-missing'),
|
||||
payload: {
|
||||
sessionId: agent.id,
|
||||
itemId: InboxItemId('claimed'),
|
||||
itemId: MessageId('claimed'),
|
||||
action: { kind: 'remove' },
|
||||
},
|
||||
})
|
||||
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
|
||||
const closed = await api.sessions.updateQueue({
|
||||
rpcId: RpcId('q-closed'),
|
||||
payload: {
|
||||
sessionId: agent.id,
|
||||
itemId: InboxItemId('closed'),
|
||||
action: { kind: 'steer' },
|
||||
},
|
||||
expect(agent.inbox.nextTurn[0]).toMatchObject({
|
||||
id: 'present',
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})
|
||||
expect(expectErr(closed)).toMatchObject({
|
||||
code: 'steer-unavailable',
|
||||
details: { itemId: 'closed' },
|
||||
})
|
||||
expect(seen).toEqual([
|
||||
{ id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } },
|
||||
{ id: 'claimed', action: { kind: 'remove' } },
|
||||
{ id: 'closed', action: { kind: 'steer' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a stale occurrence without resuming a cold agent', async () => {
|
||||
@@ -347,7 +328,7 @@ describe('session.updateQueue', () => {
|
||||
rpcId: RpcId('q-cold'),
|
||||
payload: {
|
||||
sessionId: 'cold-session' as SessionId,
|
||||
itemId: InboxItemId('stale-item'),
|
||||
itemId: MessageId('stale-item'),
|
||||
action: { kind: 'remove' },
|
||||
},
|
||||
})
|
||||
@@ -358,222 +339,64 @@ describe('session.updateQueue', () => {
|
||||
})
|
||||
|
||||
describe('session/queue frames', () => {
|
||||
it('folds nested mutations observed before their outer enqueue', async () => {
|
||||
it('publishes authoritative inbox snapshots without duplicating message identity', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const original = inboxItem('i-edit', inboxMessage('m-edit', 'before'), 'queued')
|
||||
const edited = inboxItem('i-edit', inboxMessage('m-edit', 'after'), 'queued')
|
||||
const removed = inboxItem('i-remove', inboxMessage('m-remove', 'remove me'), 'queued')
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent) return
|
||||
if (item.id === original.id) ctx.emit('agent/inbox/update', agent, edited)
|
||||
if (item.id === removed.id) ctx.emit('agent/inbox/discard', agent, [removed])
|
||||
const queued = inboxMessage('m-1', 'queued prompt')
|
||||
const edited = inboxMessage('m-1', 'edited prompt')
|
||||
const steering = inboxMessage('m-2', 'steering prompt')
|
||||
agent.inbox.splice('next-turn', 0, 0, [queued])
|
||||
agent.inbox.splice('next-step', 0, 0, [steering])
|
||||
|
||||
const abort = new AbortController()
|
||||
const iterator = api.events.mux({
|
||||
rpcId: RpcId('t-mux-baseline'),
|
||||
payload: {},
|
||||
}, abort.signal)[Symbol.asyncIterator]()
|
||||
const frames = [
|
||||
await nextFrame(iterator),
|
||||
await nextFrame(iterator),
|
||||
]
|
||||
agent.inbox.splice('next-turn', 0, 1, [edited])
|
||||
frames.push(await nextFrame(iterator), await nextFrame(iterator))
|
||||
const injected = freezeMessage({
|
||||
id: MessageId('m-3'),
|
||||
role: 'user',
|
||||
content: [{ type: 'text' as const, text: 'injected context' }],
|
||||
source: { kind: 'plugin' as const, plugin: 'approval' },
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const live = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-reentrant'), payload: {} }, live.signal), 2, live)
|
||||
agent.inbox.splice('next-step', 0, 0, [injected])
|
||||
frames.push(await nextFrame(iterator), await nextFrame(iterator))
|
||||
abort.abort()
|
||||
await iterator.return?.()
|
||||
|
||||
ctx.emit('agent/inbox/enqueue', agent, original)
|
||||
ctx.emit('agent/inbox/enqueue', agent, removed)
|
||||
|
||||
const liveFrames = (await collected).filter(frame => frame.type === 'session/queue')
|
||||
expect(liveFrames.map(frame => frame.items)).toEqual([
|
||||
[{ id: edited.id, placement: edited.placement, message: edited.message }],
|
||||
])
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-reentrant-replay'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames)
|
||||
})
|
||||
|
||||
it('expires unmatched mutations after the synchronous re-entry window', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = stubAgent(ctx)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const original = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'original'), 'queued')
|
||||
const staleEdit = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'stale edit'), 'queued')
|
||||
const staleTerminal = inboxItem('i-stale-terminal', inboxMessage('m-stale-terminal', 'keep me'), 'queued')
|
||||
|
||||
ctx.emit('agent/inbox/update', agent, staleEdit)
|
||||
ctx.emit('agent/inbox/discard', agent, [staleTerminal])
|
||||
await Promise.resolve()
|
||||
ctx.emit('agent/inbox/enqueue', agent, original)
|
||||
ctx.emit('agent/inbox/enqueue', agent, staleTerminal)
|
||||
|
||||
const replay = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-expired-unseen'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [
|
||||
{ id: original.id, placement: original.placement, message: original.message },
|
||||
{ id: staleTerminal.id, placement: staleTerminal.placement, message: staleTerminal.message },
|
||||
],
|
||||
}])
|
||||
})
|
||||
|
||||
it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const live = new AbortController()
|
||||
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
|
||||
// subscribed baseline + one snapshot per accepted inbox occurrence.
|
||||
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
|
||||
|
||||
const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued')
|
||||
const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued)
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
|
||||
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue')
|
||||
expect(liveFrames).toEqual([
|
||||
expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([
|
||||
{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [{ id: queued.id, placement: 'queued', message: queued.message }],
|
||||
items: [
|
||||
{ id: queued.id, placement: 'queued', message: queued },
|
||||
{ id: steering.id, placement: 'steering', message: steering },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [
|
||||
{ id: queued.id, placement: 'queued', message: queued.message },
|
||||
{ id: steering.id, placement: 'steering', message: steering.message },
|
||||
{ id: edited.id, placement: 'queued', message: edited },
|
||||
{ id: steering.id, placement: 'steering', message: steering },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [
|
||||
{ id: edited.id, placement: 'queued', message: edited },
|
||||
{ id: injected.id, placement: 'context', message: injected },
|
||||
{ id: steering.id, placement: 'steering', message: steering },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// A fresh mux connection replays only the current authoritative snapshot.
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]])
|
||||
})
|
||||
|
||||
it('publishes the durable steering event before retiring its transient row', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const abort = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-steering-order'), payload: {} }, abort.signal), 5, abort)
|
||||
const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering')
|
||||
|
||||
agent.session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering)
|
||||
agent.session.append('steering/message', {
|
||||
turn: 1,
|
||||
message: steering.message,
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
expect(frames.map(frame => frame.type)).toEqual([
|
||||
'session/subscribed',
|
||||
'session/event',
|
||||
'session/queue',
|
||||
'session/event',
|
||||
'session/queue',
|
||||
])
|
||||
expect(frames[2]).toMatchObject({
|
||||
type: 'session/queue',
|
||||
items: [{ id: steering.id, placement: 'steering' }],
|
||||
})
|
||||
expect(frames[3]).toMatchObject({
|
||||
type: 'session/event',
|
||||
event: { type: 'steering/message', data: { message: { id: steering.message.id } } },
|
||||
})
|
||||
expect(frames[4]).toMatchObject({ type: 'session/queue', items: [] })
|
||||
})
|
||||
|
||||
it('retains claimed steering in re-entrant snapshots until its durable event', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering')
|
||||
const queued = inboxItem('i-reentrant', inboxMessage('m-reentrant', 'later'), 'queued')
|
||||
ctx.on('agent/inbox/dequeue', (subject, item) => {
|
||||
if (subject === agent && item.id === steering.id) ctx.emit('agent/inbox/enqueue', agent, queued)
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-steering-reentrant-order'), payload: {} }, abort.signal), 6, abort)
|
||||
|
||||
agent.session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering)
|
||||
agent.session.append('steering/message', {
|
||||
turn: 1,
|
||||
message: steering.message,
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
expect(frames[3]).toMatchObject({
|
||||
type: 'session/queue',
|
||||
items: [
|
||||
{ id: steering.id, placement: 'steering' },
|
||||
{ id: queued.id, placement: 'queued' },
|
||||
],
|
||||
})
|
||||
expect(frames[4]).toMatchObject({
|
||||
type: 'session/event',
|
||||
event: { type: 'steering/message', data: { message: { id: steering.message.id } } },
|
||||
})
|
||||
expect(frames[5]).toMatchObject({
|
||||
type: 'session/queue',
|
||||
items: [{ id: queued.id, placement: 'queued' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes edits in place in the authoritative order', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const abort = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 5, abort)
|
||||
const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued')
|
||||
const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued')
|
||||
const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, first)
|
||||
ctx.emit('agent/inbox/enqueue', agent, second)
|
||||
ctx.emit('agent/inbox/update', agent, edited)
|
||||
ctx.emit('agent/inbox/dequeue', agent, edited)
|
||||
|
||||
const frames = (await collected).filter(frame => frame.type === 'session/queue')
|
||||
expect(frames.map(frame => frame.items)).toEqual([
|
||||
[{ id: first.id, placement: first.placement, message: first.message }],
|
||||
[
|
||||
{ id: first.id, placement: first.placement, message: first.message },
|
||||
{ id: second.id, placement: second.placement, message: second.message },
|
||||
],
|
||||
[
|
||||
{ id: first.id, placement: first.placement, message: first.message },
|
||||
{ id: edited.id, placement: edited.placement, message: edited.message },
|
||||
],
|
||||
[{ id: first.id, placement: first.placement, message: first.message }],
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes an empty snapshot after terminal discard', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const doomed = inboxItem('i-doomed', inboxMessage('m-5', 'doomed'), 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, doomed)
|
||||
ctx.emit('agent/inbox/discard', agent, [doomed])
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 1, abort)
|
||||
expect(frames.filter(frame => frame.type === 'session/queue')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ function liveAgent(
|
||||
): Session {
|
||||
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
|
||||
for (let turn = 1; turn <= turns; turn++) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
|
||||
source: { kind: 'user' },
|
||||
@@ -67,12 +67,15 @@ function liveAgent(
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
if (tail !== 'none') {
|
||||
session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: turns + 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'open prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
if (tail === 'aborted') session.append('turn/end', { turn: turns + 1, reason: { kind: 'aborted' } })
|
||||
if (tail === 'aborted') session.append('turn/end', {
|
||||
turn: turns + 1,
|
||||
reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
})
|
||||
}
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
return session
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { Inbox } 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, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } 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'
|
||||
@@ -49,6 +53,8 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
|
||||
const session = ctx.sessions.create()
|
||||
// The gateway reads both the session and durable inbox baseline.
|
||||
ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent)
|
||||
return { ctx, session }
|
||||
}
|
||||
|
||||
@@ -80,40 +86,6 @@ 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())
|
||||
@@ -252,7 +224,7 @@ describe('session/projection push frame', () => {
|
||||
|
||||
seedMessages(session, 1)
|
||||
// Same-reference apply: turn/start does not concern the unit — no frame.
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
seedMessages(session, 1)
|
||||
|
||||
const frames = await collected
|
||||
|
||||
@@ -57,7 +57,7 @@ async function composed(withTitles = true): Promise<Context> {
|
||||
function liveAgent(ctx: Context, id: string, turns: number): Session {
|
||||
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
|
||||
for (let turn = 1; turn <= turns; turn++) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
|
||||
source: { kind: 'user' },
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('session.search', () => {
|
||||
eventFilters: [
|
||||
{
|
||||
kind: 'type',
|
||||
values: ['user/message', 'assistant/message', 'steering/message'],
|
||||
values: ['user/message', 'assistant/message'],
|
||||
},
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
@@ -182,7 +182,7 @@ describe('session.search', () => {
|
||||
withBestMatch(0, { sessionId: sid('hidden') }),
|
||||
withBestMatch(1, { surface: 'shadowed' }),
|
||||
withBestMatch(2, { type: 'tool/result' }),
|
||||
withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }),
|
||||
withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }),
|
||||
],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('mux live view computation', () => {
|
||||
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
|
||||
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
|
||||
@@ -175,7 +175,7 @@ describe('mux live view computation', () => {
|
||||
// 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)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
|
||||
// meta rides through to presentResult's ToolResult (the spread arm).
|
||||
session.append('tool/result', {
|
||||
@@ -241,7 +241,7 @@ describe('mux live view computation', () => {
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const first = appendUserText(session, 'first prompt')
|
||||
appendAssistantText(session, 'first reply', 1)
|
||||
const third = appendUserText(session, 'second prompt')
|
||||
@@ -295,7 +295,7 @@ describe('mux live view computation', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create('session-doomed' as SessionId)
|
||||
}, { inject: ['sessions'] }))
|
||||
session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session?.append('turn/start', { turn: 1 })
|
||||
session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
|
||||
// Disposing the owning fiber detaches the session mid-stream; the
|
||||
// session/disposed listener must clear its open-call table entry.
|
||||
@@ -314,7 +314,7 @@ describe('mux live view computation', () => {
|
||||
const collected = collect(stream, 4, abort)
|
||||
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The turn/end above cleared the live table; pairing must fall back to
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
@@ -44,16 +44,15 @@ function stubAgent(session: Session): Agent {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
reserveTurnAdmission: () => undefined,
|
||||
cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,6 @@ import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../s
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
||||
import { goalEditRequestSchema } from '../src/api/goals.schema.ts'
|
||||
import {
|
||||
subagentHistoryRequestSchema, subagentHistoryValueSchema, subagentListEntrySchema,
|
||||
subagentListRequestSchema, subagentListValueSchema, subagentPromptRequestSchema,
|
||||
subagentPromptValueSchema,
|
||||
} from '../src/api/subagents.schema.ts'
|
||||
|
||||
describe('RpcId', () => {
|
||||
it('brands a raw string at zero runtime cost', () => {
|
||||
@@ -77,16 +72,9 @@ describe('rpcErrorSchema', () => {
|
||||
}).code).toBe('model-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||
expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'steer-unavailable', message: 'm', details: { itemId: 'i' } }).code).toBe('steer-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
|
||||
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
|
||||
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-parent-unavailable', message: 'm', details: { parentSessionId: 'p' } }).code).toBe('subagent-parent-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-not-found', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c' } }).code).toBe('subagent-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-delivery-unavailable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-delivery-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||
})
|
||||
|
||||
@@ -142,13 +130,7 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionIdSchema.parse('s1')).toBe('s1')
|
||||
expect(() => sessionIdSchema.parse('')).toThrow()
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true })
|
||||
expect(sessionSummarySchema.parse({
|
||||
sessionId: 's1', updatedAt: 1, running: true, blank: false,
|
||||
parentSessionId: 'p', origin: 'subagent', cwd: '/x',
|
||||
})).toMatchObject({ origin: 'subagent', cwd: '/x' })
|
||||
expect(() => sessionSummarySchema.parse({
|
||||
sessionId: 's1', updatedAt: 1, running: false, blank: false, origin: 'fork',
|
||||
})).toThrow()
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
|
||||
// blank is mandatory: a summary without it fails the parse.
|
||||
expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow()
|
||||
const event = sessionEventSchema.parse({
|
||||
@@ -280,9 +262,6 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionUpdateQueueRequestSchema.parse({
|
||||
sessionId: 's1', itemId: 'i1', action: { kind: 'remove' },
|
||||
}).action.kind).toBe('remove')
|
||||
expect(sessionUpdateQueueRequestSchema.parse({
|
||||
sessionId: 's1', itemId: 'i1', action: { kind: 'steer' },
|
||||
}).action.kind).toBe('steer')
|
||||
expect(() => sessionUpdateQueueRequestSchema.parse({
|
||||
sessionId: 's1', itemId: 'i1', action: { kind: 'promote' },
|
||||
})).toThrow()
|
||||
@@ -292,45 +271,6 @@ describe('sessions domain schemas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent domain schemas', () => {
|
||||
it('validates the direct catalog and addressed history pair', () => {
|
||||
const child = {
|
||||
kind: 'child', id: 'c', mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: true,
|
||||
}
|
||||
const oneShot = {
|
||||
kind: 'child', id: 'o', mode: 'one-shot', activity: 'inactive', hasChildren: false,
|
||||
}
|
||||
const diagnostic = { kind: 'diagnostic', id: 'bad', reason: 'unsupported' }
|
||||
expect(subagentListEntrySchema.parse(child)).toEqual(child)
|
||||
expect(subagentListEntrySchema.parse(oneShot)).toEqual(oneShot)
|
||||
expect(subagentListEntrySchema.parse(diagnostic)).toEqual(diagnostic)
|
||||
expect(() => subagentListEntrySchema.parse({
|
||||
kind: 'child', id: 'missing', mode: 'one-shot', activity: 'inactive',
|
||||
})).toThrow()
|
||||
expect(subagentListRequestSchema.parse({ parentSessionId: 'p' })).toEqual({ parentSessionId: 'p' })
|
||||
expect(subagentListValueSchema.parse({
|
||||
entries: [child, oneShot, diagnostic], parentAvailable: true,
|
||||
}).entries).toHaveLength(3)
|
||||
expect(subagentHistoryRequestSchema.parse({
|
||||
parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', beforeSeq: 4, maxMessages: 2,
|
||||
}).beforeSeq).toBe(4)
|
||||
expect(() => subagentHistoryRequestSchema.parse({
|
||||
parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', maxMessages: 0,
|
||||
})).toThrow()
|
||||
expect(subagentHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('validates continuable prompt content and the accepted inbox identity', () => {
|
||||
expect(subagentPromptRequestSchema.parse({
|
||||
parentSessionId: 'p', childSessionId: 'c', mode: 'continuable',
|
||||
content: [{ type: 'text', text: '继续' }],
|
||||
}).childSessionId).toBe('c')
|
||||
expect(subagentPromptValueSchema.parse({ messageId: 'm1' }).messageId).toBe('m1')
|
||||
expect(() => subagentPromptValueSchema.parse({ route: 'started', taskId: 't2' })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('host domain schemas', () => {
|
||||
it('validates describe request/value', () => {
|
||||
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
||||
@@ -480,7 +420,11 @@ describe('events frame schemas', () => {
|
||||
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
|
||||
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
|
||||
{ type: 'session/queue', sessionId: 's', items: [
|
||||
{ id: 'i1', placement: 'steering', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } },
|
||||
{
|
||||
id: 'm1',
|
||||
placement: 'queued',
|
||||
message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } },
|
||||
},
|
||||
] },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
@@ -510,15 +454,26 @@ describe('events frame schemas', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts every queue placement and rejects unknown placements', () => {
|
||||
const item = (placement: string) => ({ type: 'session/queue', sessionId: 's', items: [{
|
||||
id: 'm', placement,
|
||||
message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } },
|
||||
}] })
|
||||
for (const placement of ['queued', 'steering', 'context']) {
|
||||
expect(() => muxFrameSchema.parse(item(placement))).not.toThrow()
|
||||
}
|
||||
expect(() => muxFrameSchema.parse(item('bogus'))).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a queue snapshot with malformed items', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} } }] })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'm', role: 'assistant', content: [], source: { kind: 'user' } }] })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts every host frame branch', () => {
|
||||
const frames = [
|
||||
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p', origin: 'subagent' },
|
||||
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' },
|
||||
{ type: 'host/session-added', sessionId: 's', blank: true },
|
||||
{ type: 'host/session-removed', sessionId: 's' },
|
||||
{ type: 'host/session-status', sessionId: 's', running: true },
|
||||
@@ -532,9 +487,6 @@ describe('events frame schemas', () => {
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
expect(() => hostFrameSchema.parse({
|
||||
type: 'host/session-added', sessionId: 's', blank: true, origin: 'fork',
|
||||
})).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -198,10 +198,11 @@ describe('directory-picker-browse client half', () => {
|
||||
/>,
|
||||
)
|
||||
// The dialog opened at home; its confirm (browser.open) adopts the listed level.
|
||||
const openButton = await screen.findByRole('button', { name: 'browser.open' })
|
||||
openButton.click()
|
||||
const openButton = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' })
|
||||
await waitFor(() => { expect(openButton.disabled).toBe(false) })
|
||||
fireEvent.click(openButton)
|
||||
expect(props.onPicked).toHaveBeenCalledWith(HOME)
|
||||
screen.getByRole('button', { name: 'browser.cancel' }).click()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
|
||||
expect(props.onCancel).toHaveBeenCalled()
|
||||
expect(props.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user