Merge remote-tracking branch 'origin/master' into web2-todo

# Conflicts:
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-trajectory/tests/views.spec.tsx
This commit is contained in:
imccyu
2026-07-27 10:07:51 +08:00
887 changed files with 28254 additions and 9617 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 8113357bbcff2d654db7fc68c4e7903ecf0ccd72
README.zh.md: a5bf1d3cb8c96bc754938abd0dc5c70533476751
README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f
README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10

View File

@@ -12,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)

View File

@@ -12,7 +12,9 @@
mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/session-added` 则以任意到达顺序携带已提交的增量。前端 Workspace Intent 与 Session Intent 只存在于客户端,没有协议方法
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` + 根路径)

View File

@@ -42,10 +42,12 @@
"dependencies": {
"@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:^",

View File

@@ -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 type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
@@ -23,6 +23,9 @@ import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, 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'
@@ -147,6 +150,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 },
}
@@ -171,6 +175,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
@@ -365,6 +372,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)
@@ -627,7 +669,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (mode === 'steer') agent.steer(content, { source })
else agent.followup(content, { source })
} catch (error: unknown) {
// 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 })
@@ -774,6 +816,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>>()
@@ -790,6 +914,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.
@@ -839,6 +971,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 },
@@ -877,6 +1012,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() })
},

View 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'>>>

View 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 }>>
}

View File

@@ -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>

View File

@@ -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 }

View File

@@ -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, 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'

View File

@@ -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']
@@ -21,6 +27,9 @@ export interface RpcMethodMap {
'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). */

View File

@@ -39,6 +39,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>>

View File

@@ -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. */
@@ -54,9 +62,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 }>>

View 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'>>>

View 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[] }>>
}

View File

@@ -27,6 +27,8 @@ import {
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
@@ -60,6 +62,13 @@ export interface IApiClient {
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>>
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
@@ -83,6 +92,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'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). */
@@ -276,6 +288,15 @@ export abstract class AbstractApiClient implements IApiClient {
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'] = {
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),

View File

@@ -28,6 +28,8 @@ import {
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
@@ -35,11 +37,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>>>
}
}
@@ -54,6 +58,9 @@ const UNARY_ROUTES: UnaryRoutes = {
'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). */
@@ -89,14 +96,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 })
@@ -201,7 +210,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)
},
}
}

View File

@@ -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.

View File

@@ -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)

View 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 })
})
})

View File

@@ -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' }> =>

View File

@@ -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 {
@@ -40,6 +42,12 @@ function scriptedApi(overrides: {
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 })),
}
@@ -56,7 +64,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 }] })
},
},
})
@@ -65,7 +73,7 @@ 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 () => {
@@ -277,7 +285,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')
},
},

View File

@@ -71,6 +71,30 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
}
},
},
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),
host: (_request, signal) => stream(hostFrames, signal),
@@ -117,6 +141,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', () => {

View File

@@ -18,6 +18,11 @@ import {
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'
@@ -103,8 +108,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()
@@ -176,7 +183,51 @@ describe('workspace domain schemas', () => {
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', () => {
@@ -189,6 +240,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 })
@@ -207,13 +260,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 })

View File

@@ -35,6 +35,12 @@
{
"path": "../../session-title/session-title"
},
{
"path": "../../skill/skill"
},
{
"path": "../../ui/commands"
},
{
"path": "../../ui/user-approval"
},