Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .gitignore # apps/cli/package.json # apps/cli/src/web.ts # docs/architecture.i18n.yaml # docs/config-catalog.md # docs/module-graph.md # packages/client/connection/README.md # packages/client/connection/src/client/api.ts # packages/client/connection/src/client/fixture.ts # packages/client/runtime/README.md # packages/client/runtime/src/client/sessions/service.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/src/client/workspaces/service.ts # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx # packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.module.css # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/host/apiproxy/src/api-proxy.ts # pnpm-lock.yaml
This commit is contained in:
6
packages/host/apiproxy/README.i18n.yaml
Normal file
6
packages/host/apiproxy/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f
|
||||
README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-host-apiproxy
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml).
|
||||
|
||||
## Contract layer (`/api`)
|
||||
@@ -10,7 +12,9 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
|
||||
|
||||
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. Frontend Workspace and Session Intents are client-only and have no wire method.
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
The `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 and returns a detached result; 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)
|
||||
|
||||
|
||||
35
packages/host/apiproxy/README.zh.md
Normal file
35
packages/host/apiproxy/README.zh.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# @deepseek-ai/dsh-host-apiproxy
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。
|
||||
|
||||
## 契约层(`/api`)
|
||||
|
||||
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。
|
||||
|
||||
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
|
||||
|
||||
mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包定义客户端与宿主间的协议契约和载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
@@ -44,10 +44,12 @@
|
||||
"@deepseek-ai/dsh-attachment-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment-local'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
@@ -16,7 +16,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError,
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
|
||||
WorkspaceMoveInvalidError, WorkspaceNameConflictError,
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
@@ -24,6 +25,9 @@ import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// 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'
|
||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
import { RpcId } from './api/rpc.ts'
|
||||
@@ -223,6 +227,7 @@ function summarize(session: Session, running: boolean): SessionSummary {
|
||||
sessionId: session.id,
|
||||
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
|
||||
running,
|
||||
blank: session.events.length === 0,
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
}
|
||||
@@ -247,6 +252,9 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
|
||||
sessionId: meta.id,
|
||||
updatedAt,
|
||||
running: false,
|
||||
// Lazy persistence keeps never-appended sessions out of list(): a cold
|
||||
// session necessarily has events, so blank is constantly false here.
|
||||
blank: false,
|
||||
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
|
||||
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
|
||||
filters those out (legacy logs are not served); the conditional mirrors
|
||||
@@ -375,6 +383,15 @@ class SessionCwdConflict extends Error {
|
||||
/** Host failed before the registry could adopt a name-created directory. */
|
||||
class WorkspaceDirectoryCreationError extends Error {}
|
||||
|
||||
/** Shared workspace-not-found error response of the workspace.* mutation rows. */
|
||||
function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `workspace "${workspaceId}" not found`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
|
||||
/** Wire projection of one workspace entity (the workspace.* value row). */
|
||||
function workspaceView(workspace: Workspace): WorkspaceView {
|
||||
return {
|
||||
@@ -423,6 +440,41 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
for (const queue of muxQueues) queue.push(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session inbox mirror serving the mux-open queue snapshot (the same
|
||||
* refresh-recovery baseline as pending questions). Keyed by the stable
|
||||
* AgentMessageId: every enqueued id receives exactly one terminal
|
||||
* `agent/inbox/dequeue` OR `agent/inbox/discard` (the inbox contract), so
|
||||
* the mirror needs no consumption heuristics or sweeps beyond disposal.
|
||||
*/
|
||||
const queuedMirror = new Map<SessionId, Map<AgentMessageId, AgentMessage>>()
|
||||
ctx.effect(() => {
|
||||
const retire = (agent: Agent, id: AgentMessageId): void => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) return
|
||||
entries.delete(id)
|
||||
if (entries.size === 0) queuedMirror.delete(agent.id)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage) => {
|
||||
let entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) queuedMirror.set(agent.id, entries = new Map<AgentMessageId, AgentMessage>())
|
||||
entries.set(message.id, message)
|
||||
broadcast({ type: 'session/queued', sessionId: agent.id, content: message.content, source: message.source, steering: message.steering })
|
||||
}),
|
||||
ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
|
||||
retire(agent, message.id)
|
||||
}),
|
||||
ctx.on('agent/inbox/discard', (agent: Agent, messages: AgentMessage[]) => {
|
||||
for (const message of messages) retire(agent, message.id)
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
queuedMirror.delete(session.id)
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'api-proxy: queued mirror')
|
||||
|
||||
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
|
||||
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
|
||||
pendingQuestions.delete(pending.rpcId)
|
||||
@@ -702,7 +754,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: { reason: error.code },
|
||||
})
|
||||
}
|
||||
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
// A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
@@ -812,6 +864,60 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
},
|
||||
|
||||
async rename(request) {
|
||||
const { payload } = request
|
||||
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
|
||||
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
|
||||
const title = payload.title.trim()
|
||||
// Uniqueness AND the same-title no-op both ride the create chain so
|
||||
// they observe the state left by earlier queued renames — checked
|
||||
// up front, a queued A→A could report success while an earlier A→B
|
||||
// still lands afterwards.
|
||||
const operation = workspaceCreationChain.then(async () => {
|
||||
if (title === workspace.title) return
|
||||
if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
|
||||
throw new WorkspaceNameConflictError(title)
|
||||
}
|
||||
await workspace.setTitle(title)
|
||||
})
|
||||
workspaceCreationChain = operation.then(() => undefined, () => undefined)
|
||||
try {
|
||||
await operation
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkspaceNameConflictError) {
|
||||
return err(request, {
|
||||
code: 'workspace-name-conflict',
|
||||
message: error.message,
|
||||
details: { name: error.workspaceName },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return ok(request, { workspace: workspaceView(workspace) })
|
||||
},
|
||||
|
||||
async insertSessionBefore(request) {
|
||||
const { payload } = request
|
||||
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
|
||||
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
|
||||
try {
|
||||
await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
|
||||
} catch (error: unknown) {
|
||||
// Only the entity's unaccounted-id rejection is the business code;
|
||||
// storage/durability failures propagate as internal errors.
|
||||
if (!(error instanceof WorkspaceMoveInvalidError)) throw error
|
||||
return err(request, {
|
||||
code: 'workspace-move-invalid',
|
||||
message: error.message,
|
||||
details: {
|
||||
workspaceId: payload.workspaceId,
|
||||
sessionId: payload.sessionId,
|
||||
...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
|
||||
},
|
||||
})
|
||||
}
|
||||
return ok(request, { workspace: workspaceView(workspace) })
|
||||
},
|
||||
},
|
||||
|
||||
host: {
|
||||
@@ -836,6 +942,88 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
commands: {
|
||||
// Both methods address one session's agent (agentFor keeps its
|
||||
// resume-on-miss: clients only send a sessionId for a published
|
||||
// session, and resume restores an existing entity).
|
||||
async list(request) {
|
||||
// Missing service = the deployment omitted dsh-commands from its
|
||||
// composition, not an empty catalog: fail loud instead of serving [].
|
||||
const commands = ctx.get('commands')
|
||||
if (commands === undefined) {
|
||||
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
const found = await agentFor(request.payload.sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
return ok(request, { commands: commands.list(found.agent) })
|
||||
},
|
||||
|
||||
async execute(request, signal) {
|
||||
const commands = ctx.get('commands')
|
||||
if (commands === undefined) {
|
||||
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
const { sessionId, line } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const result = await commands.execute(found.agent, line, signal)
|
||||
if (result === undefined) return ok(request, { matched: false })
|
||||
return ok(request, {
|
||||
matched: true,
|
||||
result: { kind: result.kind, ...result.text === undefined ? {} : { text: result.text } },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
|
||||
return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
// Skill lookup never touches the Agent registry: the session address
|
||||
// resolves to a canonical cwd from the host-resident session header, so
|
||||
// listing skills cannot create or resume an agent as a side effect.
|
||||
async list(request) {
|
||||
const { sessionId } = request.payload
|
||||
const session = ctx.sessions.get(sessionId)
|
||||
if (session === undefined) {
|
||||
return err(request, {
|
||||
code: 'session-not-found',
|
||||
message: `session "${sessionId}" not found (not attached)`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
if (session.header.cwd === undefined) {
|
||||
// Every served session records its project at create time; a
|
||||
// cwd-less header is a pre-project legacy log (not served).
|
||||
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
|
||||
}
|
||||
const cwd = session.header.cwd
|
||||
// Same stance as the commands domain: a missing service means the
|
||||
// deployment omitted dsh-skill from its composition, not an empty
|
||||
// catalog. ctx.get also keeps this handler independent of the gateway
|
||||
// plugin's inject list (an undeclared `ctx.skills` property read
|
||||
// fails the reflect proxy).
|
||||
const skillRegistry = ctx.get('skills')
|
||||
if (skillRegistry === undefined) {
|
||||
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 })
|
||||
return ok(request, {
|
||||
skills: skills.map(skill => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
|
||||
})),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
mux(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
||||
@@ -852,6 +1040,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
})
|
||||
}
|
||||
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
|
||||
// in arrival order per session; a reconnecting client rebuilds its
|
||||
// queue view from these alone.
|
||||
for (const [sessionId, entries] of queuedMirror) {
|
||||
for (const entry of entries.values()) {
|
||||
queue.push(frame({ type: 'session/queued', sessionId, content: entry.content, source: entry.source, steering: entry.steering }))
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// opened mid-turn) backscans the session's in-memory events instead.
|
||||
@@ -901,6 +1097,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
queue.push(frame({
|
||||
type: 'host/session-added',
|
||||
sessionId: session.id,
|
||||
// Derived at frame time like summarize(); a just-created session
|
||||
// has no events yet, so this is constantly true in practice.
|
||||
blank: session.events.length === 0,
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
// cwd rides the frame so the client list needs no refresh to group the new session.
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
@@ -939,6 +1138,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
workspace: changedWorkspaceView(change.key, change.value),
|
||||
}))
|
||||
}),
|
||||
ctx.on('commands/change', () => {
|
||||
queue.push(frame({ type: 'host/commands-changed' }))
|
||||
}),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
|
||||
45
packages/host/apiproxy/src/api/commands.schema.ts
Normal file
45
packages/host/apiproxy/src/api/commands.schema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* commands domain zod schemas (names derived from map keys: commandListRequestSchema /
|
||||
* commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { CommandDescriptor, CommandExecuteResult } from './commands.ts'
|
||||
|
||||
/** CommandDescriptor row of command.list. */
|
||||
export const commandDescriptorSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
input: z.object({ hint: z.string() }).optional(),
|
||||
}) satisfies z.ZodType<Wire<CommandDescriptor>>
|
||||
|
||||
/** command.list request payload. */
|
||||
export const commandListRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.list'>>>
|
||||
|
||||
/** command.list response value. */
|
||||
export const commandListValueSchema = z.object({
|
||||
commands: z.array(commandDescriptorSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'command.list'>>>
|
||||
|
||||
/** command.execute request payload. */
|
||||
export const commandExecuteRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
line: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
|
||||
|
||||
/** Detached command outcome (result slot of command.execute's value). */
|
||||
export const commandExecuteResultSchema = z.object({
|
||||
kind: z.union([z.literal('success'), z.literal('error')]),
|
||||
text: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<CommandExecuteResult>>
|
||||
|
||||
/** command.execute response value (matched=false carries no result). */
|
||||
export const commandExecuteValueSchema = z.object({
|
||||
matched: z.boolean(),
|
||||
result: commandExecuteResultSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>
|
||||
48
packages/host/apiproxy/src/api/commands.ts
Normal file
48
packages/host/apiproxy/src/api/commands.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* commands domain contract: the web catalog/dispatch face of the host command
|
||||
* registry (`ctx.commands`). Both methods address one session's agent via
|
||||
* `sessionId` — every served session has an Agent (Session+Agent are born
|
||||
* together), so there is no agent-less surface on this wire.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
* Handler-free command view served to clients. Wire mirror of the host
|
||||
* registry descriptor (which stays host-side with its cordis dependencies);
|
||||
* no source field — the host descriptor has none.
|
||||
*/
|
||||
export interface CommandDescriptor {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
readonly name: string
|
||||
/** Human-readable summary used in discovery UI. */
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: { readonly hint: string }
|
||||
}
|
||||
|
||||
/** Detached command outcome rendered directly by the requesting client. */
|
||||
export interface CommandExecuteResult {
|
||||
readonly kind: 'success' | 'error'
|
||||
readonly text?: string
|
||||
}
|
||||
|
||||
/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */
|
||||
export interface CommandsApi {
|
||||
/**
|
||||
* Lists the addressed agent's effective command catalog (name-sorted,
|
||||
* globals plus its scoped shadows).
|
||||
*/
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
|
||||
|
||||
/**
|
||||
* Parses and executes one slash-command line against the addressed agent
|
||||
* without sending it to the model. matched=false when syntax or name does
|
||||
* not resolve (the client falls back to its default sink). The signal rides
|
||||
* beside the request, never on the wire: the fetch carrier's request signal
|
||||
* cancels the running handler.
|
||||
*/
|
||||
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
|
||||
}
|
||||
@@ -10,7 +10,7 @@ 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 { sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
@@ -35,15 +35,18 @@ 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')]) }),
|
||||
// content/source reuse the wide passthroughs (both are merge-extensible in core).
|
||||
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, content: z.array(contentBlockSchema), source: z.looseObject({ kind: z.string() }), steering: z.boolean() }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<MuxFrame>
|
||||
|
||||
/** HostFrame union (payload slot of a host-stream ServerRequest). */
|
||||
export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
|
||||
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
|
||||
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
|
||||
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
|
||||
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
|
||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm/types'
|
||||
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'
|
||||
@@ -61,20 +62,41 @@ export type MuxFrame =
|
||||
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
|
||||
| { 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 (`agent/queued` passthrough:
|
||||
* a queued message is not model-visible, so there is no session event to
|
||||
* ride — 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.
|
||||
* source carries the prompt's rpcId when the message came over this wire
|
||||
* (the client's provisional-echo reconciliation key).
|
||||
*/
|
||||
| { type: 'session/queued'; sessionId: SessionId; content: ContentBlock[]; source: MessageSource; steering: boolean }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
/**
|
||||
* Host stream frames. session-added carries the lineage anchor and the
|
||||
* project cwd (the list-summary fields a client cannot wait for a refresh to
|
||||
* learn); agent-error is the only outlet for live failures with no turn
|
||||
* position; workspace-changed pushes the full new snapshot after every
|
||||
* durable workspace mutation (create/attach/order change — the client
|
||||
* upserts, while `workspace.list` provides the reconnect baseline).
|
||||
* Host stream frames. session-added carries the lineage anchor, the project
|
||||
* cwd, and the blank bit (the list-summary fields a client cannot wait for a
|
||||
* refresh to learn); the frame fires at session/created, so blank is
|
||||
* constantly true — clients flip it on the session's first
|
||||
* `host/session-status(running:true)` (a blank session never runs), and a
|
||||
* reconnecting client takes `session.list`'s summary.blank as authoritative.
|
||||
* agent-error is the only outlet for live failures with no turn position;
|
||||
* workspace-changed pushes the full new snapshot after every durable
|
||||
* workspace mutation (create/attach/order change — the client upserts, while
|
||||
* `workspace.list` provides the reconnect baseline).
|
||||
*/
|
||||
export type HostFrame =
|
||||
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId; cwd?: string }
|
||||
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
|
||||
| { type: 'host/session-removed'; sessionId: SessionId }
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
|
||||
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
|
||||
/**
|
||||
* The command registry changed (`commands/change` passthrough). Pure
|
||||
* invalidation signal, no payload: clients refetch `command.list` in the
|
||||
* background rather than diffing.
|
||||
*/
|
||||
| { type: 'host/commands-changed' }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
|
||||
@@ -15,6 +17,8 @@ export interface ApiProxy {
|
||||
sessions: SessionsApi
|
||||
host: HostApi
|
||||
workspace: WorkspaceApi
|
||||
commands: CommandsApi
|
||||
skills: SkillsApi
|
||||
events: EventsApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
@@ -24,6 +28,8 @@ export interface ApiProxy {
|
||||
export type { HistoryEntry, PromptContentPart, SessionsApi, SessionSummary } from './sessions.ts'
|
||||
export type { HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Method name → method signature. Signatures are the single source of truth; payload/value types are always derived from here. */
|
||||
/**
|
||||
* Method name → method signature. Signatures are the single source of truth; payload/value
|
||||
* types are always derived from here. A method may declare a trailing AbortSignal after the
|
||||
* request (command.execute): the carrier passes its request signal, never a wire field.
|
||||
*/
|
||||
export interface RpcMethodMap {
|
||||
'session.list': SessionsApi['list']
|
||||
'session.create': SessionsApi['create']
|
||||
@@ -20,6 +26,11 @@ export interface RpcMethodMap {
|
||||
'host.describe': HostApi['describe']
|
||||
'workspace.list': WorkspaceApi['list']
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
'workspace.rename': WorkspaceApi['rename']
|
||||
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -40,6 +40,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface RpcErrorDetailsMap {
|
||||
'workspace-not-found': { workspaceId: string }
|
||||
'workspace-invalid-path': { path: string }
|
||||
'workspace-name-conflict': { name: string }
|
||||
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
|
||||
'agent-busy': { reason: string }
|
||||
'attachment-error': { reason: string }
|
||||
'internal': {}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const sessionSummarySchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
updatedAt: z.number(),
|
||||
running: z.boolean(),
|
||||
blank: z.boolean(),
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<SessionSummary>>
|
||||
@@ -99,6 +100,9 @@ export const sessionHistoryValueSchema = z.object({
|
||||
hasMore: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
|
||||
|
||||
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
|
||||
export const contentBlockSchema = z.looseObject({ type: z.string() })
|
||||
|
||||
/** Raster image media types accepted by the version-one browser wire. */
|
||||
export const imageMediaTypeSchema = z.union([
|
||||
z.literal('image/png'),
|
||||
|
||||
@@ -39,6 +39,14 @@ export interface SessionSummary {
|
||||
updatedAt: number
|
||||
/** Status of the attached agent; always false for cold (unattached) sessions. */
|
||||
running: boolean
|
||||
/**
|
||||
* Derived emptiness bit: true while the session log holds zero events (no
|
||||
* user message yet). Clients hide blank sessions from lists and reuse them
|
||||
* for New Session on the same workspace. Always false for cold sessions —
|
||||
* lazy persistence keeps a never-appended session out of the store, so a
|
||||
* listed cold session necessarily has events.
|
||||
*/
|
||||
blank: boolean
|
||||
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
|
||||
parentSessionId?: SessionId
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
@@ -59,9 +67,9 @@ export interface SessionsApi {
|
||||
* Creates a real session and its idle agent. At most one of `workspaceId` /
|
||||
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
|
||||
* preallocate `sessionId`: retries with the same id and cwd return the same
|
||||
* session, while a different cwd fails with `session-conflict`.
|
||||
* Workspace creation attaches the session after publication; an attach
|
||||
* failure returns `workspace-attach-failed` with the published session id.
|
||||
* session, while a different cwd fails with `session-conflict`. Workspace
|
||||
* creation attaches the session after publication; an attach failure
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
27
packages/host/apiproxy/src/api/skills.schema.ts
Normal file
27
packages/host/apiproxy/src/api/skills.schema.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* skills domain zod schemas (names derived from map keys: skillListRequestSchema /
|
||||
* skillListValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { SkillEntry } from './skills.ts'
|
||||
|
||||
/** SkillEntry row of skill.list. */
|
||||
export const skillEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
whenToUse: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<SkillEntry>>
|
||||
|
||||
/** skill.list request payload. */
|
||||
export const skillListRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'skill.list'>>>
|
||||
|
||||
/** skill.list response value. */
|
||||
export const skillListValueSchema = z.object({
|
||||
skills: z.array(skillEntrySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
|
||||
25
packages/host/apiproxy/src/api/skills.ts
Normal file
25
packages/host/apiproxy/src/api/skills.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* skills domain contract: read-only skill catalog lookup addressed by session.
|
||||
* The session's header cwd resolves to the canonical project root host-side —
|
||||
* the client never submits a raw path, and skill lookup never creates or
|
||||
* resumes an Agent.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */
|
||||
export interface SkillEntry {
|
||||
/** Kebab-case identifier referenced as `<skill>name</skill>` in prompts. */
|
||||
readonly name: string
|
||||
/** Short routing description. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
}
|
||||
|
||||
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
|
||||
export interface SkillsApi {
|
||||
/** Lists model-invocable skills for the addressed session's project root. */
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
|
||||
}
|
||||
@@ -44,3 +44,29 @@ export const workspaceCreateValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
created: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>
|
||||
|
||||
/** workspace.rename request payload: the new title must be non-blank. */
|
||||
export const workspaceRenameRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
title: z.string(),
|
||||
}).refine(
|
||||
payload => payload.title.trim() !== '',
|
||||
{ message: 'workspace.rename requires a non-blank title' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>>
|
||||
|
||||
/** workspace.rename response value. */
|
||||
export const workspaceRenameValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
|
||||
|
||||
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
|
||||
export const workspaceInsertSessionBeforeRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
sessionId: sessionIdSchema,
|
||||
beforeSessionId: sessionIdSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>>
|
||||
|
||||
/** workspace.insertSessionBefore response value. */
|
||||
export const workspaceInsertSessionBeforeValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
|
||||
@@ -24,7 +24,10 @@ export interface WorkspaceView {
|
||||
path: string
|
||||
/** Unique display title (defaults to the path basename at create). */
|
||||
title: string
|
||||
/** Sessions accounted under this workspace, newest-first for display. */
|
||||
/**
|
||||
* Sessions accounted under this workspace, in manually owned order
|
||||
* (attach prepends, insertSessionBefore reorders; activity never does).
|
||||
*/
|
||||
sessionIds: SessionId[]
|
||||
/** ISO-8601 creation instant. */
|
||||
createdAt: string
|
||||
@@ -52,4 +55,27 @@ export interface WorkspaceApi {
|
||||
*/
|
||||
create(request: RpcRequest<{ path?: string; name?: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
|
||||
|
||||
/**
|
||||
* Renames a workspace. `title` is trimmed and must be non-empty
|
||||
* (schema-enforced). An unknown id fails with `workspace-not-found`; a
|
||||
* title equal to another workspace's fails with `workspace-name-conflict`.
|
||||
* Renaming to the current title is a no-op success (no durable write).
|
||||
*/
|
||||
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
|
||||
/**
|
||||
* Moves an accounted session within its workspace's manual order,
|
||||
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
|
||||
* before that anchor; omitted appends to the end. An unknown workspace
|
||||
* fails with `workspace-not-found`; a session or anchor not accounted by
|
||||
* the workspace fails with `workspace-move-invalid`. A move to the current
|
||||
* position is a no-op success.
|
||||
*/
|
||||
insertSessionBefore(request: RpcRequest<{
|
||||
workspaceId: WorkspaceId
|
||||
sessionId: SessionId
|
||||
beforeSessionId?: SessionId
|
||||
}>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
}
|
||||
|
||||
@@ -24,8 +24,12 @@ import {
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
workspaceCreateValueSchema,
|
||||
workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListValueSchema,
|
||||
workspaceRenameValueSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -57,6 +61,15 @@ export interface IApiClient {
|
||||
workspace: {
|
||||
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
|
||||
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
|
||||
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
|
||||
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
}
|
||||
commands: {
|
||||
list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.list'>>>
|
||||
execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.execute'>>>
|
||||
}
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
@@ -80,6 +93,11 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
}
|
||||
|
||||
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -270,6 +288,17 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
|
||||
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
|
||||
}
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload, signal) => this.callUnary('command.list', payload, signal),
|
||||
execute: (payload, signal) => this.callUnary('command.execute', payload, signal),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
|
||||
@@ -25,8 +25,12 @@ import {
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema,
|
||||
workspaceListRequestSchema,
|
||||
workspaceRenameRequestSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -34,11 +38,13 @@ import {
|
||||
* payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
|
||||
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
|
||||
* documented on Wire); the dispatch point carries the one Wire→exact cast.
|
||||
* Every invoke receives the carrier Request's signal; methods whose contract
|
||||
* declares a signal parameter (command.execute) forward it, the rest ignore it.
|
||||
*/
|
||||
type UnaryRoutes = {
|
||||
[K in keyof RpcMethodMap]: {
|
||||
schema: z.ZodType<Wire<RequestPayload<K>>>
|
||||
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>): Promise<RpcResponse<ResponseValue<K>>>
|
||||
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +58,11 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
|
||||
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
|
||||
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
|
||||
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
@@ -87,14 +98,16 @@ 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
|
||||
async function handleUnary<K extends keyof RpcMethodMap>(api: ApiProxy, method: K, message: ClientRequest): Promise<Response> {
|
||||
async function handleUnary<K extends keyof RpcMethodMap>(
|
||||
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const route = UNARY_ROUTES[method]
|
||||
const payload = route.schema.safeParse(message.payload)
|
||||
if (!payload.success) {
|
||||
return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } })
|
||||
}
|
||||
try {
|
||||
return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }))
|
||||
return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal))
|
||||
} catch (error: unknown) {
|
||||
// The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer.
|
||||
return new Response(`handler failure: ${String(error)}`, { status: 500 })
|
||||
@@ -199,7 +212,7 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
|
||||
if (message.method !== method) {
|
||||
return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } })
|
||||
}
|
||||
return handleUnary(api, method, message)
|
||||
return handleUnary(api, method, message, req.signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
readonly workspace: ApiProxy['workspace']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
|
||||
@@ -71,6 +73,8 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
this.sessions = api.sessions
|
||||
this.workspace = api.workspace
|
||||
this.host = api.host
|
||||
this.commands = api.commands
|
||||
this.skills = api.skills
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
// the unbound-method lint without changing behavior.
|
||||
|
||||
@@ -65,6 +65,9 @@ describe('sessions.list cold merge', () => {
|
||||
const [a, b, c] = items
|
||||
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
|
||||
expect(a?.running).toBe(false)
|
||||
// Cold summaries are never blank: lazy persistence keeps never-appended
|
||||
// sessions out of list(), so a listed session necessarily has events.
|
||||
expect(items.every(item => !item.blank)).toBe(true)
|
||||
expect(a?.cwd).toBe('/proj')
|
||||
expect(a?.parentSessionId).toBeUndefined()
|
||||
expect(b?.updatedAt).toBe(2000)
|
||||
|
||||
314
packages/host/apiproxy/tests/api-proxy-commands.spec.ts
Normal file
314
packages/host/apiproxy/tests/api-proxy-commands.spec.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Command/skill RPC handlers and the two new frames over createApiProxy:
|
||||
* command.list serves the addressed agent's effective catalog (missing
|
||||
* registry = loud internal error), command.execute dispatches through the
|
||||
* registry with the carrier signal, skill.list resolves cwd from the session
|
||||
* header (never via the Agent registry), the host stream broadcasts
|
||||
* commands-changed, and the mux stream carries live queued frames plus the
|
||||
* open-time queue snapshot.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentMessage } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import type { HostFrame, MuxFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
let nextRpc = 1
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
function expectErr<T>(response: RpcResponse<T>): { code: string; message: string } {
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
return response.result.error
|
||||
}
|
||||
|
||||
/** Composition floor for the command/skill paths (no LLM, no persistence). */
|
||||
async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (options.skills !== false) await ctx.plugin(SkillService, {})
|
||||
if (options.commands !== false) await ctx.plugin(CommandService)
|
||||
// Host-stream opener reads the committed-workspace baseline; the stub
|
||||
// suffices here — the real workspace composition is api-proxy-workspace.spec's.
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
|
||||
function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
|
||||
const session = ctx.sessions.create(sessionId)
|
||||
const agent = { id: session.id, session, status: 'idle', ctx } as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Drain `count` frames from a stream, then abort it. */
|
||||
async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number, abort: AbortController): Promise<F[]> {
|
||||
const frames: F[] = []
|
||||
for await (const frame of iterable) {
|
||||
frames.push(frame.payload)
|
||||
if (frames.length >= count) abort.abort()
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('command.list', () => {
|
||||
it('serves the addressed agent\'s name-sorted catalog', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) })
|
||||
ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '<x>' }, handler: () => ({ kind: 'success' }) })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const value = expectOk(await api.commands.list(request({ sessionId: agent.id })))
|
||||
expect(value.commands).toEqual([
|
||||
{ name: 'alpha', description: 'a', input: { hint: '<x>' } },
|
||||
{ name: 'zeta', description: 'z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fails loud with internal when the command registry is not mounted', async () => {
|
||||
const ctx = await harness({ commands: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId })))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('command registry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('command.execute', () => {
|
||||
it('executes a known command against the addressed agent and detaches the result', async () => {
|
||||
const ctx = await harness()
|
||||
let received: string | undefined
|
||||
ctx.commands.register({
|
||||
name: 'goal',
|
||||
description: 'set goal',
|
||||
handler: (invocation) => {
|
||||
received = invocation.rawInput
|
||||
return { kind: 'success', text: `goal:${invocation.agent.id}` }
|
||||
},
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
|
||||
expect(value).toEqual({ matched: true, result: { kind: 'success', text: `goal:${agent.id}` } })
|
||||
expect(received).toBe(' ship it')
|
||||
})
|
||||
|
||||
it('returns matched:false when syntax or name does not resolve', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const signal = new AbortController().signal
|
||||
expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false })
|
||||
expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false })
|
||||
})
|
||||
|
||||
it('maps a session miss to session-not-found and a registry gap to internal', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const missing = expectErr(await api.commands.execute(
|
||||
request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal))
|
||||
expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
|
||||
|
||||
const bare = await harness({ commands: false })
|
||||
const bareApi = createApiProxy(bare, DEFAULTS)
|
||||
expect(expectErr(await bareApi.commands.execute(
|
||||
request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal')
|
||||
})
|
||||
|
||||
it('reports an aborted handler as cancelled and a throwing handler as internal', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.commands.register({
|
||||
name: 'hang',
|
||||
description: 'never settles on its own',
|
||||
handler: () => new Promise(() => { /* settled only by abort */ }),
|
||||
})
|
||||
ctx.commands.register({
|
||||
name: 'boom',
|
||||
description: 'throws',
|
||||
handler: () => { throw new Error('kaboom') },
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal)
|
||||
controller.abort()
|
||||
expect(expectErr(await pending).code).toBe('cancelled')
|
||||
|
||||
const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal))
|
||||
expect(thrown.code).toBe('internal')
|
||||
expect(thrown.message).toContain('kaboom')
|
||||
})
|
||||
})
|
||||
|
||||
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({
|
||||
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,
|
||||
}])
|
||||
},
|
||||
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.
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
|
||||
expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }])
|
||||
expect(seenCwds).toEqual(['/proj'])
|
||||
expect(ctx.agents.get(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud on an unattached session id (business error, no resume attempt)', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId })))
|
||||
expect(error.code).toBe('session-not-found')
|
||||
})
|
||||
|
||||
it('fails loud with internal when the skill registry is not mounted', async () => {
|
||||
const ctx = await harness({ skills: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const error = expectErr(await api.skills.list(request({ sessionId: session.id })))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('skill registry is absent')
|
||||
})
|
||||
|
||||
it('folds a provider failure into internal', async () => {
|
||||
const ctx = await harness()
|
||||
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 }))
|
||||
// dsh-skill contains one provider's failure (logs and serves the rest), so
|
||||
// this surfaces as an empty ok catalog rather than an error.
|
||||
const value = expectOk(response)
|
||||
expect(value.skills).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('host/commands-changed frame', () => {
|
||||
it('broadcasts on registry change', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
|
||||
const collected = collect<HostFrame>(stream, 1, abort)
|
||||
ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
|
||||
expect(await collected).toEqual([{ type: 'host/commands-changed' }])
|
||||
})
|
||||
})
|
||||
|
||||
/** Build one frozen inbox message for the live `agent/inbox/*` events. */
|
||||
function inboxMessage(id: string, text: string, steering: boolean, rpcId?: string): AgentMessage {
|
||||
return Object.freeze({
|
||||
id: AgentMessageId(id),
|
||||
content: [{ type: 'text' as const, text }],
|
||||
source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
|
||||
contexts: [],
|
||||
steering,
|
||||
wakeup: true,
|
||||
})
|
||||
}
|
||||
|
||||
describe('session/queued frames', () => {
|
||||
it('forwards live enqueue events and replays the snapshot on a later mux open', 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)
|
||||
|
||||
const queued = inboxMessage('m-1', 'queued prompt', false)
|
||||
const steering = inboxMessage('m-2', 'queued prompt', true)
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued)
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
|
||||
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
|
||||
expect(liveFrames).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, content: queued.content, source: { kind: 'user' }, steering: false },
|
||||
{ type: 'session/queued', sessionId: agent.id, content: steering.content, source: { kind: 'user' }, steering: true },
|
||||
])
|
||||
|
||||
// A fresh mux connection replays the still-pending entries as its baseline.
|
||||
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')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('retires mirror entries on their terminal dequeue', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const queued = inboxMessage('m-3', 'x', false)
|
||||
const steering = inboxMessage('m-4', 'x', true, 'r-1')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued)
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
ctx.emit('agent/inbox/dequeue', agent, queued)
|
||||
ctx.emit('agent/inbox/dequeue', agent, 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)
|
||||
})
|
||||
|
||||
it('retires mirror entries on a batch discard (cancel path)', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const doomed = inboxMessage('m-5', 'doomed', false)
|
||||
const survivor = inboxMessage('m-6', 'survivor', false)
|
||||
ctx.emit('agent/inbox/enqueue', agent, doomed)
|
||||
ctx.emit('agent/inbox/enqueue', agent, survivor)
|
||||
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({ content: survivor.content })
|
||||
})
|
||||
})
|
||||
@@ -217,7 +217,8 @@ describe('Host Workspace increments', () => {
|
||||
increments.push(next.value.payload)
|
||||
}
|
||||
expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({
|
||||
type: 'host/session-added', sessionId, cwd: workspace.path,
|
||||
// A just-created session has no events: the frame constantly carries blank:true.
|
||||
type: 'host/session-added', sessionId, blank: true, cwd: workspace.path,
|
||||
})
|
||||
const workspaceChanged = increments.find(
|
||||
(increment): increment is Extract<HostFrame, { type: 'host/workspace-changed' }> =>
|
||||
|
||||
@@ -20,6 +20,8 @@ function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>>
|
||||
function scriptedApi(overrides: {
|
||||
sessions?: Partial<ApiProxy['sessions']>
|
||||
host?: Partial<ApiProxy['host']>
|
||||
commands?: Partial<ApiProxy['commands']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
events?: Partial<ApiProxy['events']>
|
||||
respond?: ApiProxy['respond']
|
||||
} = {}): ApiProxy {
|
||||
@@ -41,7 +43,15 @@ function scriptedApi(overrides: {
|
||||
workspace: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
|
||||
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
},
|
||||
commands: {
|
||||
list: r => ok(r, { commands: [] }),
|
||||
execute: r => ok(r, { matched: false }),
|
||||
...overrides.commands,
|
||||
},
|
||||
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
}
|
||||
@@ -58,7 +68,7 @@ describe('unary round trip', () => {
|
||||
sessions: {
|
||||
list: (r) => {
|
||||
seen = r
|
||||
return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false }] })
|
||||
return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false, blank: false }] })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -67,7 +77,20 @@ describe('unary round trip', () => {
|
||||
expect(seen?.payload).toEqual({ cursor: 'c1' })
|
||||
expect(seen?.rpcId).toBeTruthy()
|
||||
expect(response.rpcId).toBe(seen?.rpcId)
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } })
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('routes workspace rename and insertSessionBefore through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
|
||||
expect(renamed.result.ok).toBe(true)
|
||||
const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' })
|
||||
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
|
||||
expect(anchored.result.ok).toBe(true)
|
||||
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
|
||||
expect(appended.result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('passes business errors through as 200 + err result, not a throw', async () => {
|
||||
@@ -266,7 +289,7 @@ describe('SSE stream path', () => {
|
||||
const api = scriptedApi({
|
||||
events: {
|
||||
async *host(request): AsyncGenerator<RpcRequest<HostFrame>> {
|
||||
yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1') } }
|
||||
yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1'), blank: true } }
|
||||
throw new Error('impl died mid-stream')
|
||||
},
|
||||
},
|
||||
|
||||
@@ -58,6 +58,42 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } },
|
||||
}
|
||||
},
|
||||
async rename(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
async insertSessionBefore(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } } }
|
||||
},
|
||||
async execute(request, signal) {
|
||||
if (request.payload.line === '/hang') {
|
||||
// Cooperative hang: settles only through the carrier signal (sticky
|
||||
// abort checked first — listeners never fire retroactively).
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
|
||||
}
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } }
|
||||
}
|
||||
if (request.payload.line.startsWith('/plan')) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, result: { kind: 'success' as const, text: 'plan set' } } } }
|
||||
}
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: (_request, signal) => stream(muxFrames, signal),
|
||||
@@ -100,6 +136,32 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
expect((await c.host.describe({})).result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips command.list / command.execute / skill.list through the wire form', async () => {
|
||||
const c = client()
|
||||
const list = await c.commands.list({ sessionId: 's' as never })
|
||||
expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } })
|
||||
const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' })
|
||||
expect(hit.result).toEqual({ ok: true, value: { matched: true, result: { kind: 'success', text: 'plan set' } } })
|
||||
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
|
||||
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
|
||||
const skills = await c.skills.list({ sessionId: 's' as never })
|
||||
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into command.execute', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } })
|
||||
// The fake's /hang settles only when the invoke-level signal aborts: a
|
||||
// completed response with the cancelled error proves req.signal reached it.
|
||||
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal }))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
|
||||
expect(parsed.rpcId).toBe('r-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handler carrier-layer statuses', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { RpcId, transportError } from '../src/api/rpc.ts'
|
||||
import {
|
||||
clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
|
||||
rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema,
|
||||
@@ -14,9 +14,16 @@ import {
|
||||
} from '../src/api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema,
|
||||
workspaceListValueSchema, workspaceViewSchema,
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
} from '../src/api/workspace.schema.ts'
|
||||
import {
|
||||
commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema,
|
||||
commandListRequestSchema, commandListValueSchema,
|
||||
} from '../src/api/commands.schema.ts'
|
||||
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
||||
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
||||
@@ -31,6 +38,13 @@ describe('RpcId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('transportError', () => {
|
||||
it('folds Error and non-Error throws into the internal error branch', () => {
|
||||
expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } })
|
||||
expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rpcErrorSchema', () => {
|
||||
it('accepts every code branch with its required details', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
||||
@@ -41,6 +55,7 @@ describe('rpcErrorSchema', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid')
|
||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||
expect(rpcErrorSchema.parse({ code: 'attachment-error', message: 'm', details: { reason: 'r' } }).code).toBe('attachment-error')
|
||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||
@@ -95,8 +110,10 @@ describe('sessions domain schemas', () => {
|
||||
it('validates ids, summaries, and the event passthrough envelope', () => {
|
||||
expect(sessionIdSchema.parse('s1')).toBe('s1')
|
||||
expect(() => sessionIdSchema.parse('')).toThrow()
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toMatchObject({ sessionId: 's1' })
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true })
|
||||
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
|
||||
// blank is mandatory: a summary without it fails the parse.
|
||||
expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow()
|
||||
const event = sessionEventSchema.parse({ type: 'user/message', seq: 0, time: 1, data: { any: true } })
|
||||
expect(event).toMatchObject({ type: 'user/message' })
|
||||
expect(() => sessionEventSchema.parse({ type: 'user/message', seq: -1, time: 1, data: {} })).toThrow()
|
||||
@@ -169,6 +186,63 @@ describe('workspace domain schemas', () => {
|
||||
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
|
||||
})
|
||||
|
||||
it('rename requires a non-blank title (both refine arms)', () => {
|
||||
expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new')
|
||||
expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/)
|
||||
expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
|
||||
expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commands domain schemas', () => {
|
||||
it('validates the catalog request/value pair', () => {
|
||||
expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
// The wire is session-addressed only: a sessionId-less payload fails.
|
||||
expect(() => commandListRequestSchema.parse({})).toThrow()
|
||||
expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([])
|
||||
const value = commandListValueSchema.parse({ commands: [
|
||||
{ name: 'plan', description: 'Toggle plan mode' },
|
||||
{ name: 'goal', description: 'Set the goal', input: { hint: '<goal>' } },
|
||||
] })
|
||||
expect(value.commands[1]?.input?.hint).toBe('<goal>')
|
||||
expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined()
|
||||
expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow()
|
||||
expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow()
|
||||
})
|
||||
|
||||
it('validates the execute request/value pair with both matched branches', () => {
|
||||
expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off')
|
||||
// Both members are mandatory: dropping either fails the parse.
|
||||
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
|
||||
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
|
||||
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
|
||||
const matched = commandExecuteValueSchema.parse({ matched: true, result: { kind: 'success', text: 'done' } })
|
||||
expect(matched.result?.kind).toBe('success')
|
||||
expect(commandExecuteValueSchema.parse({ matched: true, result: { kind: 'error', text: 'bad' } }).result?.kind).toBe('error')
|
||||
expect(() => commandExecuteValueSchema.parse({ matched: true, result: { kind: 'other' } })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills domain schemas', () => {
|
||||
it('validates the list request/value pair', () => {
|
||||
expect(skillListRequestSchema.parse({ sessionId: 's1' })).toEqual({ sessionId: 's1' })
|
||||
// The wire is session-addressed only: a sessionId-less payload fails.
|
||||
expect(() => skillListRequestSchema.parse({})).toThrow()
|
||||
expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([])
|
||||
const value = skillListValueSchema.parse({ skills: [
|
||||
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' },
|
||||
{ name: 'bare', description: 'No guidance' },
|
||||
] })
|
||||
expect(value.skills[0]?.whenToUse).toBe('when committing')
|
||||
expect(value.skills[1]?.whenToUse).toBeUndefined()
|
||||
expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('events frame schemas', () => {
|
||||
@@ -181,6 +255,8 @@ 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', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' }, steering: false },
|
||||
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' }, steering: true },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
@@ -199,13 +275,20 @@ 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', content: [{ type: 'text' }], source: { kind: 'user' } })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: 'x', source: { kind: 'user' }, steering: false })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: {}, steering: false })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts every host frame branch', () => {
|
||||
const frames = [
|
||||
{ type: 'host/session-added', sessionId: 's', parentSessionId: 'p' },
|
||||
{ type: 'host/session-added', sessionId: 's' },
|
||||
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' },
|
||||
{ type: 'host/session-added', sessionId: 's', blank: true },
|
||||
{ type: 'host/session-removed', sessionId: 's' },
|
||||
{ type: 'host/session-status', sessionId: 's', running: true },
|
||||
{ type: 'host/agent-error', sessionId: 's', message: 'boom' },
|
||||
{ type: 'host/commands-changed' },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
|
||||
6
packages/host/webserver/README.i18n.yaml
Normal file
6
packages/host/webserver/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: c589c32c4e641e188f19ac6c5ad2e88e3eb79be3
|
||||
README.zh.md: 767195086b90a76160d87865caebf514ca75b0e3
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-host-webserver
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
|
||||
|
||||
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
25
packages/host/webserver/README.zh.md
Normal file
25
packages/host/webserver/README.zh.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-host-webserver
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
朴素的 HTTP 路由注册插件(默认导出 `WebServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
|
||||
|
||||
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。
|
||||
|
||||
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包只是浏览器与其他插件所注册路由之间的纯 HTTP 载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。
|
||||
- **初始 MIME 表很精简**:Vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。
|
||||
- **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。
|
||||
Reference in New Issue
Block a user