Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md
#	docs/architecture.i18n.yaml
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/defensive-patterns.i18n.yaml
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/core/scope/src/scoped-events.generated.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal-session/src/index.ts
#	packages/goal/goal-session/tests/goal-session.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/goal/tests/projection.spec.ts
#	packages/goal/tool-goal/tests/tool-goal.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
#	scripts/gen-cordis-catalog.ts
#	scripts/type-equiv.manifest.json
This commit is contained in:
_Kerman
2026-07-30 14:04:53 +08:00
1175 changed files with 49683 additions and 8452 deletions

View File

@@ -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: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74
README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9
README.md: 7129842a0cc89f5fa10c0bceec7cf0997ac71a99
README.zh.md: 8765627236ea7af9e5cd3b7029133181ef499905

View File

@@ -12,17 +12,19 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Session 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 model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes 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` creates a unique name or adopts an existing directory, `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. 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()`.
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `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; the carrier's request 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; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
## Carrier layer (`/client` + root)

View File

@@ -12,17 +12,19 @@
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方模型推理reasoning目标以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering中途引导不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent绝不恢复冷会话因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`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` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
## 载体层(`/client` + 根路径)

View File

@@ -51,6 +51,7 @@
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",

View File

@@ -9,11 +9,11 @@ import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus,
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, InboxItemId,
} from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
@@ -38,6 +38,14 @@ import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
import type {} from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-skill'
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
// Side-effect type import: resolves the `approval/request` waterfall and
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
import type {} from '@deepseek-ai/dsh-user-approval'
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
import { RpcId } from './api/rpc.ts'
@@ -128,9 +136,9 @@ class FrameQueue<F> {
}
/**
* Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
* for answerable frames belong to the approval/question registry, absent in
* this minimal version).
* Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable
* frames — approval/question requested — mint their stable id in their
* pending registries instead).
*/
function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
@@ -217,6 +225,36 @@ export interface ApiProxyDefaults {
/** The tool/call payload fields the presenter path reads. */
interface ToolCallData { callId: string; name: string; arguments: string }
/**
* One outstanding approval question: the stable server-request id, the frame
* material replayed to late mux subscribers, and the resolver that settles the
* answerer's promise back into `ctx.approval`.
*/
interface PendingApproval {
rpcId: RpcId
sessionId: SessionId
approvalId: ApprovalRequestId
toolName: string
callId?: CallId
reason?: string
resolve(outcome: ApprovalOutcome): void
}
/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */
function requestedFrame(pending: PendingApproval): RpcRequest<MuxFrame> {
return {
rpcId: pending.rpcId,
payload: {
type: 'approval/requested',
sessionId: pending.sessionId,
approvalId: pending.approvalId,
toolName: pending.toolName,
...pending.callId === undefined ? {} : { callId: pending.callId },
...pending.reason === undefined ? {} : { reason: pending.reason },
},
}
}
/** One host-owned question wait, addressed by the stable server-request id. */
interface PendingQuestion {
rpcId: RpcId
@@ -416,6 +454,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/** Serializes path ownership checks with record creation across spellings. */
let workspaceCreationChain = Promise.resolve()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const pendingApprovals = new Map<RpcId, PendingApproval>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/**
@@ -469,46 +508,106 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
/**
* Per-session inbox occurrence mirror serving the mux-open queue snapshot
* Per-session queued-occurrence mirror serving the mux-open queue snapshot
* (the same refresh-recovery baseline as pending questions). Each terminal
* inbox event retires one matching occurrence, so repeated sends of the same
* queue 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, UserMessage[]>()
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)
}
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,
message: item.message,
})),
})
}
ctx.effect(() => {
const retire = (agent: Agent, id: MessageId): void => {
const retire = (agent: Agent, item: InboxItem): boolean => {
const entries = queuedMirror.get(agent.id)
if (entries === undefined) return
const index = entries.findIndex(message => message.id === id)
if (index !== -1) entries.splice(index, 1)
if (entries === undefined) {
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
return false
}
const index = entries.findIndex(entry => entry.id === item.id)
if (index === -1) {
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
return false
}
entries.splice(index, 1)
if (entries.length === 0) queuedMirror.delete(agent.id)
return true
}
const disposers = [
ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type !== 'agent/inbox/added') return
const agent = ctx.agents.get(session.id)
if (agent === undefined || agent.session !== session) return
const message = event.data
ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
if (item.placement !== 'queued') return
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(message)
broadcast({
type: 'session/queued',
sessionId: agent.id,
message,
})
entries.push(unseen?.kind === 'update' ? unseen.item : item)
publishQueue(agent.id)
}),
ctx.on('agent/inbox/admitted', (agent: Agent, message: UserMessage) => {
retire(agent, message.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/canceled', (agent: Agent, message: UserMessage) => {
retire(agent, message.id)
ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => {
if (retire(agent, item)) 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() }
@@ -564,6 +663,90 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}, 'api-proxy: user-interaction provider')
// --- Approval pending registry ------------------------------------------
// The proxy is the approval channel for every agent this host owns: an ask
// through `ctx.approval` becomes an answerable server-request on the mux
// stream (stable rpcId), settled by POST /api/respond. The entry survives
// client disconnects — mux-open replays still-pending requested frames with
// the same rpcId (the refresh-recovery baseline) — and withdraws on the
// ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
if (ctx.get('approval') !== undefined) {
// Teardown parity with the question provider above: a gateway disposed
// while approvals are pending settles every entry as 'cancelled' (the
// service's fail-closed vocabulary), so no ask promise dangles past the
// proxy's lifetime and subscribers see the withdrawal.
ctx.effect(() => () => {
for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled')
}, 'api-proxy: approval registry teardown')
ctx.on('approval/request', (req, next) => {
// Dispatch rides a microtask behind the service's own signal check: an
// abort landing in that window would register the abort listener AFTER
// the signal fired — never invoked, entry pending forever, zombie frame
// on every mux replay. Settle synchronously instead of publishing.
if (req.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
// The audit pair `approval/asked` is already appended by the service
// before dispatch, but dispatch rides a microtask: parallel tool calls
// can append several asked events before any answerer runs. THIS
// request's event is therefore the newest asked event that is still
// undecided, unclaimed by another pending entry, and — when the ask
// names a call — carries the same callId.
const events = req.agent.session.events
const claimed = new Set<ApprovalRequestId>()
for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId)
const decided = new Set<ApprovalRequestId>()
let approvalId: ApprovalRequestId | undefined
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i] as SessionEvent
if (event.type === 'approval/decided') {
decided.add(event.data.id)
} else if (event.type === 'approval/asked') {
if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
// Symmetric pairing: a callId-bearing ask only takes its own call's
// record, and a callId-less ask only takes a callId-less record —
// so neither shape can steal the other's audit id under parallel
// asks. (Today every producer — the tool executor — passes callId;
// the callId-less arm guards any future non-tool asker.)
if ((req.callId ?? null) !== (event.data.callId ?? null)) continue
approvalId = event.data.id
break
}
}
// No asked event means the request bypassed the service's audit path —
// not this channel's question; delegate to the fail-closed default.
if (approvalId === undefined) return next()
const id = approvalId
return new Promise<ApprovalOutcome>((resolve) => {
const settle = (outcome: ApprovalOutcome): void => {
/* v8 ignore next 3 -- defensive double-settle guard: respond() routes
through the pending table (a settled id is not-pending before it can
re-settle) and the first settle removes the abort listener, so no
reachable path settles twice; kept against future settle callers. */
if (!pendingApprovals.delete(pending.rpcId)) return
req.signal?.removeEventListener('abort', onAbort)
broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome })
// A cancelled ask was already settled by the service's own signal
// race, which discards this late resolution; resolving is a no-op
// there and keeps this promise from dangling forever.
resolve(outcome)
}
const onAbort = (): void => { settle('cancelled') }
const pending: PendingApproval = {
rpcId: RpcId(randomUUID()),
sessionId: req.agent.session.id,
approvalId: id,
toolName: req.toolName,
...req.callId === undefined ? {} : { callId: req.callId },
...req.reason === undefined ? {} : { reason: req.reason },
resolve: settle,
}
pendingApprovals.set(pending.rpcId, pending)
req.signal?.addEventListener('abort', onAbort, { once: true })
const envelope = requestedFrame(pending)
for (const queue of muxQueues) queue.push(envelope)
})
})
}
/**
* Gate the cold path on the store: an id absent from it, or naming a legacy
* log without a cwd (pre-release stance: not served, no compatibility), is
@@ -928,6 +1111,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async rename(request) {
const { sessionId, title } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const titles = ctx.get('sessionTitle')
if (titles === undefined) {
return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} })
}
try {
const accepted = titles.rename(found.agent.session, title)
return ok(request, { title: accepted.title, seq: accepted.eventSeq })
} catch (error: unknown) {
// Only the input's fault maps to title-invalid (the message is
// product-user-visible in the rename dialog); liveness and disposal
// races are deployment trouble, not a bad title.
if (error instanceof SessionTitleInvalidError) {
return err(request, {
code: 'title-invalid',
message: error.message,
details: { sessionId },
})
}
return err(request, {
code: 'internal',
message: `failed to rename session "${sessionId}": ${String(error)}`,
details: {},
})
}
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const found = await agentFor(sessionId)
@@ -946,6 +1159,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return ok(request, { accepted: true as const })
},
updateQueue(request) {
const { sessionId, itemId, action } = request.payload
const agent = ctx.agents.get(sessionId)
if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') {
return Promise.resolve(err(request, {
code: 'queue-item-not-found',
message: 'queued item is no longer pending',
details: { itemId },
}))
}
return Promise.resolve(ok(request, { accepted: true as const }))
},
cancel(request) {
const { sessionId } = request.payload
const agent = ctx.agents.get(sessionId)
@@ -1304,7 +1530,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
}
try {
const skills = await skillRegistry.list({ cwd })
const skills = (await skillRegistry.list({ cwd }))
.filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
return ok(request, {
skills: skills.map(skill => ({
name: skill.name,
@@ -1334,17 +1561,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
})
}
// Refresh recovery: still-pending approval questions replay with their
// stable rpcId so a reconnecting client can still answer them.
for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending))
// 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, entries] of queuedMirror) {
for (const message of entries) {
queue.push(frame({
type: 'session/queued',
sessionId,
message,
}))
}
for (const [sessionId, items] of queuedMirror) {
queue.push(frame({
type: 'session/queue',
sessionId,
items: items.map(item => ({
id: item.id,
message: item.message,
})),
}))
}
// 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
@@ -1450,6 +1681,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Route by the echoed rpcId (the wire correlation): approvals first,
// then questions — the two registries share one id space of UUIDs.
const approval = pendingApprovals.get(message.rpcId)
if (approval !== undefined) {
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
const parsed = approvalResponsePayloadSchema.safeParse(message.result.value)
// The payload's audit correlation must match the entry the rpcId routed
// to — a mismatched answer is malformed, not merely late.
if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
approval.resolve(parsed.data.outcome)
return Promise.resolve({ accepted: true })
}
const pending = pendingQuestions.get(message.rpcId)
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) {

View File

@@ -10,7 +10,9 @@ import type { HostFrame, MuxFrame } from './events.ts'
import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
import {
contentBlockSchema, inboxItemIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
} from './sessions.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
@@ -42,7 +44,14 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
// and must fail loud here, not reach the composer.
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema }),
z.object({
type: z.literal('session/queue'),
sessionId: sessionIdSchema,
items: z.array(z.object({
id: inboxItemIdSchema,
message: messageSchema,
})),
}),
// value stays wide: it already passed its unit's own schema on the host,
// and deep-validating here would import every domain's schema into the carrier.
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),

View File

@@ -9,6 +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 { 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'
@@ -31,6 +32,14 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/** One pending queued occurrence in an authoritative queue snapshot. */
export interface QueuedInboxItem {
/** Agent-owned occurrence identity used by queue mutations. */
id: InboxItemId
/** Complete pending message; it is not durable until the Agent claims it. */
message: Message
}
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
export interface EventsApi {
/**
@@ -62,16 +71,13 @@ export type MuxFrame =
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
/**
* A message entered the addressed agent's inbox. A queued message is not
* model-visible, so there is no session event to carry it; this transient
* frame is the only wire signal. On stream open the
* host replays the current queue snapshot for every attached session (same
* refresh-recovery baseline as pending questions); queue clearing on cancel
* has no dedicated frame — clients fold it from the status flip.
* `message.source` carries the prompt's rpcId when the message came over
* this wire (the client's provisional-echo reconciliation key).
* Complete transient queue state after every enqueue, mutation, claim, or
* discard. Pending work is not model-visible and therefore has no durable
* session event; the whole snapshot makes edit, deletion, cancel, and
* reconnect converge through one authoritative signal. Pending steering is
* outside this Web queue projection.
*/
| { type: 'session/queued'; sessionId: SessionId; message: Message }
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the

View File

@@ -29,13 +29,13 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'
@@ -56,6 +56,7 @@ 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'
// ---- Method registry and derived generics ----
export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'

View File

@@ -23,7 +23,9 @@ export interface RpcMethodMap {
'session.history': SessionsApi['history']
'session.models': SessionsApi['models']
'session.selectModel': SessionsApi['selectModel']
'session.rename': SessionsApi['rename']
'session.prompt': SessionsApi['prompt']
'session.updateQueue': SessionsApi['updateQueue']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']

View File

@@ -47,8 +47,10 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -9,6 +9,7 @@ import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
import type { Branded } from '@deepseek-ai/dsh-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
@@ -44,10 +45,12 @@ export interface RpcErrorDetailsMap {
'directory-create-failed': { path: string }
'directory-picker-unavailable': { capability: string }
'agent-busy': { reason: string }
'queue-item-not-found': { itemId: InboxItemId }
/** 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. */
'unknown-command': {}
'title-invalid': { sessionId: SessionId }
'internal': {}
}

View File

@@ -7,6 +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 { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
@@ -19,6 +20,9 @@ import type { WorkspaceId } from './workspace.ts'
/** 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>
/**
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
* than in workspace.schema because session.create references it while
@@ -73,6 +77,18 @@ export const sessionCreateValueSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.create'>>>
/** session.rename request payload (raw title; host-side normalization decides acceptance). */
export const sessionRenameRequestSchema = z.object({
sessionId: sessionIdSchema,
title: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.rename'>>>
/** session.rename response value (the normalized accepted title and its event seq). */
export const sessionRenameValueSchema = z.object({
title: z.string().min(1),
seq: z.number().int().nonnegative(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
export const sessionHistoryRequestSchema = z.object({
sessionId: sessionIdSchema,
@@ -202,6 +218,21 @@ export const sessionPromptValueSchema = z.object({
}).optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
/** session.updateQueue request payload. */
export const sessionUpdateQueueRequestSchema = z.object({
sessionId: sessionIdSchema,
itemId: inboxItemIdSchema,
action: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }),
z.object({ kind: z.literal('remove') }),
]),
}) as unknown as z.ZodType<RequestPayload<'session.updateQueue'>>
/** session.updateQueue response value. */
export const sessionUpdateQueueValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'session.updateQueue'>>>
/** session.cancel request payload. */
export const sessionCancelRequestSchema = z.object({
sessionId: sessionIdSchema,

View File

@@ -5,6 +5,7 @@
*/
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.
@@ -124,6 +125,11 @@ export interface SessionModels {
failures: ModelCatalogFailure[]
}
/** A client-requested mutation of one still-pending queue item. */
export type QueueAction =
| { kind: 'edit'; content: ContentBlock[] }
| { kind: 'remove' }
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
sessionId: SessionId
@@ -208,6 +214,16 @@ export interface SessionsApi {
}>):
Promise<RpcResponse<{ selected: ModelTarget }>>
/**
* Renames a session: appends a `session/title` event with the `user`
* source, which pins the title against automatic regeneration. The
* normalized accepted title and the title event's seq return so the caller
* can settle its projection cell without waiting for the push frame. A
* title that normalizes to empty fails with `title-invalid`.
*/
rename(request: RpcRequest<{ sessionId: SessionId; title: string }>):
Promise<RpcResponse<{ title: string; seq: number }>>
/**
* Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer.
* A prompt whose content is exactly one text block starting with '/' is a slash command: the host
@@ -219,6 +235,13 @@ export interface SessionsApi {
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/**
* Edits or removes one pending queued occurrence.
*/
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
Promise<RpcResponse<{ accepted: true }>>
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
}

View File

@@ -20,6 +20,6 @@ export interface SkillEntry {
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
export interface SkillsApi {
/** Lists model-invocable skills for the addressed session's project root. */
/** Lists skills usable by the browser's user-selected model-reference path. */
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
}

View File

@@ -24,7 +24,9 @@ import {
sessionListValueSchema,
sessionModelsValueSchema,
sessionPromptValueSchema,
sessionRenameValueSchema,
sessionSelectModelValueSchema,
sessionUpdateQueueValueSchema,
} from '../api/sessions.schema.ts'
import {
workspaceCreateValueSchema,
@@ -66,7 +68,9 @@ export interface IApiClient {
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
host: {
@@ -116,7 +120,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.history': sessionHistoryValueSchema,
'session.models': sessionModelsValueSchema,
'session.selectModel': sessionSelectModelValueSchema,
'session.rename': sessionRenameValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
@@ -327,7 +333,9 @@ export abstract class AbstractApiClient implements IApiClient {
history: (payload, signal) => this.callUnary('session.history', payload, signal),
models: (payload, signal) => this.callUnary('session.models', payload, signal),
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}

View File

@@ -21,7 +21,9 @@ import {
sessionListRequestSchema,
sessionModelsRequestSchema,
sessionPromptRequestSchema,
sessionRenameRequestSchema,
sessionSelectModelRequestSchema,
sessionUpdateQueueRequestSchema,
} from '../api/sessions.schema.ts'
import {
hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
@@ -68,7 +70,9 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
@@ -123,7 +127,7 @@ function fullResponse(narrow: RpcResponse<unknown>): Response {
*/
// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
async function handleUnary<K extends keyof RpcMethodMap>(
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
): Promise<Response> {

View File

@@ -0,0 +1,330 @@
/**
* Approval pending registry over the proxy: an ask through `ctx.approval`
* becomes an answerable `approval/requested` mux frame (stable rpcId, replayed
* verbatim on a later mux open), `respond` routes by the echoed rpcId and
* validates the audit correlation, and the ask's abort signal withdraws the
* question with a broadcast `cancelled`.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId as mintRpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
return { ctx, api }
}
/** 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' } } })
return { session } as unknown as Agent
}
/** Open a mux stream and capture frames into an array (returns an on-demand waiter). */
function openMux(api: ApiProxy, abort: AbortController): { frames: MuxFrame[]; envelopes: RpcRequest<MuxFrame>[]; waitFor(type: MuxFrame['type']): Promise<MuxFrame> } {
const frames: MuxFrame[] = []
const envelopes: RpcRequest<MuxFrame>[] = []
const waiters: { type: MuxFrame['type']; resolve: (frame: MuxFrame) => void }[] = []
void (async () => {
for await (const envelope of api.events.mux({ rpcId: mintRpcId('t-mux'), payload: {} }, abort.signal)) {
frames.push(envelope.payload)
envelopes.push(envelope)
for (let i = waiters.length - 1; i >= 0; i -= 1) {
const waiter = waiters[i] as (typeof waiters)[number]
if (waiter.type === envelope.payload.type) {
waiters.splice(i, 1)
waiter.resolve(envelope.payload)
}
}
}
})()
return {
frames,
envelopes,
waitFor: (type) => {
const found = frames.find(frame => frame.type === type)
if (found !== undefined) return Promise.resolve(found)
return new Promise((resolve) => { waiters.push({ type, resolve }) })
},
}
}
function requestedOf(frame: MuxFrame): Extract<MuxFrame, { type: 'approval/requested' }> {
if (frame.type !== 'approval/requested') throw new Error(`expected approval/requested, got ${frame.type}`)
return frame
}
/** Wait until the stream delivered `count` frames of `type` (bounded poll; waitFor only covers the first). */
async function waitForCount(mux: { frames: MuxFrame[] }, type: MuxFrame['type'], count: number): Promise<void> {
for (let i = 0; i < 200 && mux.frames.filter(frame => frame.type === type).length < count; i += 1) {
await new Promise(resolve => setTimeout(resolve, 5))
}
expect(mux.frames.filter(frame => frame.type === type).length).toBeGreaterThanOrEqual(count)
}
function answer(rpcId: RpcId, sessionId: unknown, approvalId: ApprovalRequestId, outcome: 'allowed-once' | 'rejected'): Parameters<ApiProxy['respond']>[0] {
return { type: 'client-response', rpcId, result: { ok: true, value: { sessionId, approvalId, outcome } } }
}
describe('approval pending registry', () => {
it('round-trips ask → requested frame → respond → outcome + resolved broadcast', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
const asked = ctx.approval.request({ agent, toolName: 'bash', reason: 'sandbox escalation' })
const requested = requestedOf(await mux.waitFor('approval/requested'))
expect(requested).toMatchObject({ toolName: 'bash', reason: 'sandbox escalation', sessionId: agent.session.id })
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
const receipt = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))
expect(receipt).toEqual({ accepted: true })
await expect(asked).resolves.toBe('allowed-once')
const resolved = await mux.waitFor('approval/resolved')
expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'allowed-once' })
// The question settled: a duplicate answer is late, not re-decidable.
const dup = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'rejected'))
expect(dup).toEqual({ accepted: false, reason: 'not-pending' })
abort.abort()
})
it('replays a still-pending requested frame (same rpcId) on a later mux open', async () => {
const { ctx, api } = await harness()
const first = new AbortController()
const firstMux = openMux(api, first)
const agent = agentOf(ctx)
const asked = ctx.approval.request({ agent, toolName: 'write' })
const requested = requestedOf(await firstMux.waitFor('approval/requested'))
const firstEnvelope = firstMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
first.abort()
// A fresh subscriber (refresh recovery) sees the same stable rpcId.
const second = new AbortController()
const secondMux = openMux(api, second)
const replayed = requestedOf(await secondMux.waitFor('approval/requested'))
const secondEnvelope = secondMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
expect(secondEnvelope.rpcId).toBe(firstEnvelope.rpcId)
expect(replayed.approvalId).toBe(requested.approvalId)
const receipt = await api.respond(answer(secondEnvelope.rpcId, replayed.sessionId, replayed.approvalId, 'rejected'))
expect(receipt).toEqual({ accepted: true })
await expect(asked).resolves.toBe('rejected')
second.abort()
})
it('rejects malformed and mismatched answers as bad-response, unknown ids as not-pending', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
void ctx.approval.request({ agent, toolName: 'bash' })
const requested = requestedOf(await mux.waitFor('approval/requested'))
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
// Unknown rpcId: not routed to any pending entry.
expect(await api.respond(answer(mintRpcId('ghost'), requested.sessionId, requested.approvalId, 'rejected')))
.toEqual({ accepted: false, reason: 'not-pending' })
// Error-branch result: the client can only answer with a value.
expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
// Wrong audit correlation: the rpcId routed, but the payload disagrees.
expect(await api.respond(answer(envelope.rpcId, requested.sessionId, 'other-approval' as ApprovalRequestId, 'rejected')))
.toEqual({ accepted: false, reason: 'bad-response' })
// Malformed payload shape.
expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: true, value: { nonsense: 1 } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
abort.abort()
})
it('withdraws the question on the ask signal: cancelled outcome, resolved broadcast, late answer not-pending', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
const cancel = new AbortController()
const asked = ctx.approval.request({ agent, toolName: 'bash', signal: cancel.signal })
const requested = requestedOf(await mux.waitFor('approval/requested'))
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
cancel.abort()
await expect(asked).resolves.toBe('cancelled')
const resolved = await mux.waitFor('approval/resolved')
expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' })
expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')))
.toEqual({ accepted: false, reason: 'not-pending' })
abort.abort()
})
it('an ask whose signal aborted before dispatch settles cancelled without publishing', async () => {
// The service checks the signal, then dispatch rides a microtask: an
// abort in that window must not register a dead listener and strand the
// entry (zombie frame on every replay). Drive the waterfall directly
// with a pre-aborted signal to hit the answerer's register-path guard.
const { ctx, api } = await harness()
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('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' })
const agent = { session } as unknown as Agent
const cancelled = new AbortController()
cancelled.abort()
const outcome = await ctx.waterfall(
'approval/request',
{ agent, toolName: 'bash', signal: cancelled.signal },
() => Promise.resolve('unavailable' as const),
)
expect(outcome).toBe('cancelled')
// Nothing was published: a fresh mux open replays no approval frame.
const abort2 = new AbortController()
const mux2 = openMux(api, abort2)
await new Promise(resolve => setTimeout(resolve, 10))
expect(mux2.envelopes.some(e => e.payload.type === 'approval/requested')).toBe(false)
abort2.abort()
abort.abort()
void mux
})
it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => {
// Mount the proxy on its own fiber so disposal exercises the teardown
// effect while an ask is still pending.
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
let api!: ApiProxy
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
await fiber.await()
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.approval.request({ agent: agentOf(ctx), toolName: 'bash' })
const requested = requestedOf(await mux.waitFor('approval/requested'))
await fiber.dispose()
await expect(asked).resolves.toBe('cancelled')
const resolved = await mux.waitFor('approval/resolved')
expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' })
abort.abort()
})
it('carries callId on the frame and ignores a late abort after the answer settled', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
const cancel = new AbortController()
const asked = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-9' as never, signal: cancel.signal })
const requested = requestedOf(await mux.waitFor('approval/requested'))
expect(requested.callId).toBe('call-9')
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')))
.toEqual({ accepted: true })
await expect(asked).resolves.toBe('allowed-once')
// Late abort: the pending entry is gone; settle's delete-guard returns.
cancel.abort()
expect(mux.frames.filter(f => f.type === 'approval/resolved')).toHaveLength(1)
abort.abort()
})
it('pairs parallel asks by callId: each requested frame carries its own audit id', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
// Both asks append their approval/asked audit events before either
// answerer's microtask dispatch runs — the parallel tool-call window.
const askA = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-a' as never })
const askB = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-b' as never })
await waitForCount(mux, 'approval/requested', 2)
const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested')
const frameA = frames.find(e => requestedOf(e.payload).callId === 'call-a') as RpcRequest<MuxFrame>
const frameB = frames.find(e => requestedOf(e.payload).callId === 'call-b') as RpcRequest<MuxFrame>
// Each frame claimed the asked event with its own callId, not merely the newest.
const askedIdByCall = new Map(agent.session.events
.filter(event => event.type === 'approval/asked')
.map(event => [String(event.data.callId), event.data.id]))
expect(requestedOf(frameA.payload).approvalId).toBe(askedIdByCall.get('call-a'))
expect(requestedOf(frameB.payload).approvalId).toBe(askedIdByCall.get('call-b'))
// Answers route back to the right ask through the pairing.
expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected')))
.toEqual({ accepted: true })
expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once')))
.toEqual({ accepted: true })
await expect(askA).resolves.toBe('allowed-once')
await expect(askB).resolves.toBe('rejected')
abort.abort()
})
it('gives parallel callId-less asks distinct audit ids (claimed-entry skip); both stay answerable', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
const askA = ctx.approval.request({ agent, toolName: 'alpha' })
const askB = ctx.approval.request({ agent, toolName: 'beta' })
await waitForCount(mux, 'approval/requested', 2)
const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested')
const frameA = frames.find(e => requestedOf(e.payload).toolName === 'alpha') as RpcRequest<MuxFrame>
const frameB = frames.find(e => requestedOf(e.payload).toolName === 'beta') as RpcRequest<MuxFrame>
// Without a callId the pairing is heuristic, but never shared: the second
// dispatch skips the id the first pending entry already claimed.
expect(requestedOf(frameA.payload).approvalId).not.toBe(requestedOf(frameB.payload).approvalId)
expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once')))
.toEqual({ accepted: true })
expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected')))
.toEqual({ accepted: true })
await expect(askA).resolves.toBe('allowed-once')
await expect(askB).resolves.toBe('rejected')
abort.abort()
})
it('delegates a dispatch whose only asked candidate is already decided (stale re-dispatch)', async () => {
const { ctx, api } = await harness()
void api // the answerer is registered; the fake below bypasses the service
// 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('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
const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'bash' }, () => Promise.resolve('unavailable' as const))
expect(outcome).toBe('unavailable')
})
it('delegates an ask whose session log carries no asked audit event (foreign channel)', async () => {
const { ctx, api } = await harness()
void api // the answerer is registered; the fake below bypasses the audit path
// 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' } } })
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')
})
})

View File

@@ -1,7 +1,7 @@
/**
* The summary blank bit means "conversation not started" (no turn has run),
* not "log empty": standalone plugin events — command lifecycle records,
* plan/mode, session titles — never flip it, so running /plan or /goal on a
* plan/mode, permission knob events, session titles — never flip it, so running /plan or /goal on a
* fresh session keeps it list-hidden and reusable, while the first accepted
* prompt's turn/start clears it. The host/session-added frame shares the
* same predicate function (covered by the workspace spec's frame assertion).
@@ -15,6 +15,10 @@ import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
// Side-effect type imports: the knob-event SessionEventMap merges.
import type {} from '@deepseek-ai/dsh-permission'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
@@ -48,6 +52,10 @@ function appendStandalone(session: Session): void {
session.append('session/title', {
title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' },
})
// The three permission knob events (a /permission switch on a fresh session).
session.append('permission/preset', { preset: 'danger-full-access' })
session.append('sandbox/mode', { mode: 'danger-full-access' })
session.append('approval/policy', { policy: 'never' })
}
async function listBlank(api: ApiProxy, id: string): Promise<boolean | undefined> {

View File

@@ -9,10 +9,10 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
* open-time queue snapshot.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
import type { Agent, InboxItem, InboxPlacement } 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'
@@ -180,17 +180,35 @@ describe('skill.list', () => {
it('lists skills for the session cwd taken from the header', async () => {
const ctx = await harness()
const seenCwds: (string | undefined)[] = []
ctx.skills.registerProvider({
ctx.skills.registerProvider(() => ({
name: 'probe',
list: (options) => {
seenCwds.push(options.cwd)
return Promise.resolve([{
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
source: 'custom', provider: 'probe', rank: 0, locator: null,
}])
return Promise.resolve([
{
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
invocation: { modelInvocable: true, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'user-only', description: 'User-only',
invocation: { modelInvocable: false, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'model-only', description: 'Model-only',
invocation: { modelInvocable: true, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'trusted-only', description: 'Trusted-only',
invocation: { modelInvocable: false, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
])
},
get: () => Promise.resolve(undefined),
})
}))
const api = createApiProxy(ctx, DEFAULTS)
// No agent is registered for this session: header resolution must not
// touch (or resume through) the Agent registry.
@@ -219,11 +237,11 @@ describe('skill.list', () => {
it('folds a provider failure into internal', async () => {
const ctx = await harness()
ctx.skills.registerProvider({
ctx.skills.registerProvider(() => ({
name: 'broken',
list: () => Promise.reject(new Error('directory exploded')),
get: () => Promise.resolve(undefined),
})
}))
const api = createApiProxy(ctx, DEFAULTS)
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
const response = await api.skills.list(request({ sessionId: session.id }))
@@ -256,84 +274,159 @@ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
})
}
describe('session/queued frames', () => {
it('forwards live enqueue events and replays the snapshot on a later mux open', async () => {
/** 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 an addressable action 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 })
return id === InboxItemId('present') ? 'applied' : 'not-found'
}
const api = createApiProxy(ctx, DEFAULTS)
const applied = await api.sessions.updateQueue({
rpcId: RpcId('q-apply'),
payload: {
sessionId: agent.id,
itemId: InboxItemId('present'),
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
},
})
expect(expectOk(applied)).toEqual({ accepted: true })
const missing = await api.sessions.updateQueue({
rpcId: RpcId('q-missing'),
payload: {
sessionId: agent.id,
itemId: InboxItemId('claimed'),
action: { kind: 'remove' },
},
})
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
expect(seen).toEqual([
{ id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } },
{ id: 'claimed', action: { kind: 'remove' } },
])
})
it('rejects a stale occurrence without resuming a cold agent', async () => {
const ctx = await harness()
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, DEFAULTS)
const response = await api.sessions.updateQueue({
rpcId: RpcId('q-cold'),
payload: {
sessionId: 'cold-session' as SessionId,
itemId: InboxItemId('stale-item'),
action: { kind: 'remove' },
},
})
expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' })
expect(resume).not.toHaveBeenCalled()
})
})
describe('session/queue frames', () => {
it('folds nested mutations observed before their outer enqueue', async () => {
const ctx = await harness()
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 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)
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, 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('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 + 2 queued frames
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
// subscribed baseline + one queued snapshot; pending steering stays off this wire.
const liveCollected = collect<MuxFrame>(liveStream, 2, live)
const queued = inboxMessage('m-1', 'queued prompt')
const steering = inboxMessage('m-2', 'queued prompt')
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
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/queued')
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue')
expect(liveFrames).toEqual([
{ type: 'session/queued', sessionId: agent.id, message: queued, steering: false },
{ type: 'session/queued', sessionId: agent.id, message: steering, steering: true },
{
type: 'session/queue',
sessionId: agent.id,
items: [{ id: queued.id, message: queued.message }],
},
])
// A fresh mux connection replays the still-pending entries as its baseline.
// 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), 3, replay)
expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames)
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay)
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]])
})
it('retires mirror entries on their terminal dequeue', async () => {
it('publishes edits in place in the authoritative order', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const queued = inboxMessage('m-3', 'x')
const steering = inboxMessage('m-4', 'x', 'r-1')
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
ctx.emit('agent/inbox/dequeue', agent, queued, 'queued')
ctx.emit('agent/inbox/dequeue', agent, steering, 'steering')
const abort = new AbortController()
const frames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort)
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
})
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)
it('retires the matching placement when one message identity is queued and steering', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const repeated = inboxMessage('m-repeat', 'same prompt')
ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued')
ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering')
ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued')
ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering')
const abort = new AbortController()
const frames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-repeat'), payload: {} }, abort.signal), 2, abort)
expect(frames.filter(f => f.type === 'session/queued')).toEqual([
{ type: 'session/queued', sessionId: agent.id, message: repeated, steering: false },
const frames = (await collected).filter(frame => frame.type === 'session/queue')
expect(frames.map(frame => frame.items)).toEqual([
[{ id: first.id, message: first.message }],
[{ id: first.id, message: first.message }, { id: second.id, message: second.message }],
[{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }],
[{ id: first.id, message: first.message }],
])
})
it('retires mirror entries on a batch discard (cancel path)', async () => {
it('publishes an empty snapshot after terminal discard', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const doomed = inboxMessage('m-5', 'doomed')
const survivor = inboxMessage('m-6', 'survivor')
ctx.emit('agent/inbox/enqueue', agent, doomed, 'queued')
ctx.emit('agent/inbox/enqueue', agent, survivor, 'queued')
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), 2, abort)
const remaining = frames.filter(f => f.type === 'session/queued')
expect(remaining).toHaveLength(1)
expect(remaining[0]).toMatchObject({ message: survivor })
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 1, abort)
expect(frames.filter(frame => frame.type === 'session/queue')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,129 @@
/**
* sessions.rename delegation through the composed SessionTitleService. The
* agent factory is a structural stub whose createAgent forwards seed/meta into
* the real SessionStore, and whose resume never runs (every source here is
* already attached). Cold-session resolution is the shared `agentFor` path —
* api-proxy-cold.spec.ts owns the resume evidence for every unary that rides
* it, rename included.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (id: string): SessionId => id as SessionId
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`fr-${String(nextRpc++)}`), payload }
}
async function composed(withTitles = true): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
if (withTitles) {
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 })
}
// Store-backed structural factory: create builds the session with the
// forwarded seed/meta (the store validates the balanced prefix) and
// registers an idle agent stub over it.
ctx.agents.setFactory({
createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {
...options.seed === undefined ? {} : { seed: [...options.seed] },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent
ctx.agents.register(agent)
return Promise.resolve({ agent, dispose: () => Promise.resolve() })
},
resume: () => Promise.reject(new Error('resume must not run: every source is attached')),
})
return ctx
}
/** Register one live agent whose log holds `turns` completed turns. */
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('user/message', createUserMessage({
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return session
}
const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-rename', 1)
const renamed = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' new name ' }))
expect(renamed.result.ok).toBe(true)
if (!renamed.result.ok) return
expect(renamed.result.value.title).toBe('new name')
const event = source.events.findLast(item => item.type === 'session/title')
expect(event?.seq).toBe(renamed.result.value.seq)
expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } })
})
it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-rename-bad', 1)
// U+200B passes a client-side trim gate but normalizes to empty host-side.
const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ' }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) {
expect(response.result.error).toMatchObject({
code: 'title-invalid',
details: { sessionId: source.id },
})
// The message renders verbatim in the rename dialog's alert.
expect(response.result.error.message).toBe('session title must contain visible characters')
}
})
it('maps a non-validation rename failure (stale session object) to internal, not title-invalid', async () => {
const ctx = await composed()
// The registered agent holds a session object from another store: the
// title service's liveness check throws a plain Error, which must not
// read as the user's fault.
const foreign = await composed(false)
const stale = liveAgent(foreign, 'session-rename-stale', 1)
ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
const response = await api(ctx).sessions.rename(request({ sessionId: stale.id, title: 'name' }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('internal')
})
it('answers internal when the composition mounts no session-title service', async () => {
const ctx = await composed(false)
const source = liveAgent(ctx, 'session-no-titles', 1)
const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: 'name' }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) {
expect(response.result.error.code).toBe('internal')
expect(response.result.error.message).toMatch(/mounts no session-title service/)
}
})
})

View File

@@ -47,6 +47,8 @@ function stubAgent(session: Session): Agent {
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
inject: () => {},

View File

@@ -46,7 +46,9 @@ function scriptedApi(overrides: {
selectModel: r => ok(r, {
selected: { provider: r.payload.provider, model: r.payload.model },
}),
rename: r => ok(r, { title: 'renamed', seq: 0 }),
prompt: r => ok(r, { accepted: true as const }),
updateQueue: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,
},

View File

@@ -67,9 +67,15 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
}
},
async rename(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
},
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
async updateQueue(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
async cancel(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
@@ -204,7 +210,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
})
it('covers create/prompt/cancel/describe passthrough', async () => {
it('covers create/prompt/updateQueue/cancel/describe passthrough', async () => {
const c = client()
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
@@ -224,7 +230,14 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
},
},
})
const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.updateQueue({
sessionId: 's' as never,
itemId: 'item-1' as never,
action: { kind: 'remove' },
})).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.host.describe({})).result.ok).toBe(true)
})

View File

@@ -11,6 +11,7 @@ import {
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema,
} from '../src/api/sessions.schema.ts'
import {
hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
@@ -68,13 +69,16 @@ describe('rpcErrorSchema', () => {
details: { provider: 'p', model: 'm' },
}).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: '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: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
it('rejects a known code with missing details', () => {
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
})
@@ -219,7 +223,19 @@ describe('sessions domain schemas', () => {
expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' })
expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow()
expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionUpdateQueueRequestSchema.parse({
sessionId: 's1',
itemId: 'i1',
action: { kind: 'edit', content: [{ type: 'text', text: 'next' }] },
}).action.kind).toBe('edit')
expect(sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: 'i1', action: { kind: 'remove' },
}).action.kind).toBe('remove')
expect(() => sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: 'i1', action: { kind: 'promote' },
})).toThrow()
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(sessionUpdateQueueValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 })
})
})
@@ -363,8 +379,9 @@ describe('events frame schemas', () => {
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
{ 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/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false },
{ type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true },
{ type: 'session/queue', sessionId: 's', items: [
{ id: 'i1', 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: {} } },
]
@@ -382,10 +399,10 @@ describe('events frame schemas', () => {
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
})
it('rejects a queued frame missing its members', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).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()
})
it('accepts every host frame branch', () => {

View File

@@ -41,6 +41,9 @@
{
"path": "../../session-projection/session-projection-cache"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../skill/skill"
},