Merge remote-tracking branch 'origin/doc/host-client-group-readmes' into feat/directory-picker

# Conflicts:
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/tests/fake-api.ts
#	packages/client/runtime/src/client/workspaces/service.ts
#	packages/client/runtime/tests/fake-api.ts
#	packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
#	packages/client/ui-workspace/src/client/WorkspacePicker.tsx
#	packages/client/ui-workspace/tests/workspace-picker.spec.tsx
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/host.schema.ts
#	packages/host/apiproxy/src/api/host.ts
#	packages/host/apiproxy/src/api/rpc-map.ts
#	packages/host/apiproxy/src/fetch/client.ts
#	packages/host/apiproxy/src/fetch/handler.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/host/apiproxy/tests/client-handler.spec.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
This commit is contained in:
creatixchu
2026-07-28 21:21:21 +08:00
750 changed files with 15381 additions and 5577 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 8db8a1b77913677d88dbbbfbdbfc28a90c0d0415
README.zh.md: 1f957223bb98afb65ab8a313c49b58c701e42be0
README.md: 3639722ab25826af8f7a0721f22d244f78b4210b
README.zh.md: 3ac81fb412d4e4caf192953bc7a7c846a1d29965

View File

@@ -10,7 +10,9 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
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.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
@@ -18,9 +20,9 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); `host.describe.directoryPicker` advertises the capability kind the client renders for, and a method called outside the advertised kind fails with `directory-picker-unavailable`. Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side 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.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
## Carrier layer (`/client` + root)

View File

@@ -10,7 +10,9 @@
分层与协议决策记录在 [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`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方模型推理reasoning目标以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`
@@ -18,9 +20,9 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md)`host.describe.directoryPicker` 广播客户端应按其渲染的能力 kind调用广播之外的方法会以 `directory-picker-unavailable` 失败。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
## 载体层(`/client` + 根路径)

View File

@@ -47,7 +47,7 @@
"@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-session-projection": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",

View File

@@ -9,14 +9,13 @@ import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus,
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement,
} from '@deepseek-ai/dsh-agent'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -26,9 +25,11 @@ import {
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
import type {} from '@deepseek-ai/dsh-session-projection'
// 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'
@@ -40,6 +41,7 @@ import type {
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import { openNativePath } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -129,26 +131,9 @@ function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
/** Project the latest durable title without exposing title-generation policy. */
function titleFrame(session: Session): SessionTitleFrame | undefined {
const title = foldSessionTitle(session.events)
if (title === undefined) return undefined
return {
type: 'session/title',
sessionId: session.id,
title: title.title,
eventSeq: title.eventSeq,
updatedAt: title.updatedAt,
}
}
/** Queue the subscription baseline followed by its optional title snapshot. */
/** Queue the subscription baseline frame. */
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
const title = titleFrame(session)
if (title !== undefined) queue.push(frame(title))
}
/** SessionSummary projection for attached (in-memory) sessions. */
@@ -209,13 +194,12 @@ export interface ApiProxyDefaults {
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
/** Native open-with-default-application; injectable for carrier tests. */
openPath?: (path: string, signal: AbortSignal) => Promise<void>
}
/** The tool/call payload fields the presenter path reads. */
interface ToolCallData { callId: string; name: string; arguments: string }
/** The tool/result payload fields the presenter path reads. */
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
/** One host-owned question wait, addressed by the stable server-request id. */
interface PendingQuestion {
rpcId: RpcId
@@ -262,10 +246,16 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const { callId, content, isError, meta } = event.data as ToolResultData
const { message, meta } = event.data
const [result] = message.content
const callId = message.source.callId
const call = argsFor(callId) as { name: string; args: unknown } | undefined
if (call === undefined) return undefined
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, {
content: result.content,
isError: result.isError === true,
...meta === undefined ? {} : { meta },
})
return view === undefined ? undefined : { for: 'result', view }
}
} catch (error: unknown) {
@@ -298,13 +288,19 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
return undefined
}
/** Current todo projection: the latest `todo/write` over the full log (whole-list replace ⇒ last write wins); undefined when none. */
function backscanTodos(events: readonly SessionEvent[]): TodoItem[] | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]
if (event !== undefined && event.type === 'todo/write') return event.data.todos
}
return undefined
/**
* The projection baseline for one history tail page: the registry's
* watermark-cache snapshot — one fully synchronous read (no await between the
* page slice and this), so all values and `asOfSeq` form a single consistent
* cut and `asOfSeq` equals the window tail event seq. The carrier holds zero
* domain knowledge (each value passed its unit's own schema inside the
* registry). An absent registry means the deployment has no projection seam:
* the whole block is absent and clients treat every key as capability-absent.
*/
function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | undefined {
const registry = ctx.get('sessionProjections')
if (registry === undefined) return undefined
return registry.snapshot(agent.session)
}
/**
@@ -423,42 +419,53 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
for (const queue of muxQueues) queue.push(envelope)
}
// Projection change feed → session/projection push frames. The carrier
// mints the wire frame (the seam package holds no wire vocabulary); the
// child activates only when a projection registry is composed, and the
// subscription unwinds with this gateway's fiber.
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
broadcast({ type: 'session/projection', sessionId: session.id, key, value, seq })
})
})
/**
* 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.
* Per-session inbox occurrence mirror serving the mux-open queue snapshot
* (the same refresh-recovery baseline as pending questions). Each terminal
* inbox event retires one matching occurrence, so repeated sends of the same
* identified message remain visible until every occurrence is claimed.
*/
const queuedMirror = new Map<SessionId, Map<AgentMessageId, { message: AgentMessage; steering: boolean }>>()
const queuedMirror = new Map<SessionId, { message: UserMessage; steering: boolean }[]>()
ctx.effect(() => {
const retire = (agent: Agent, id: AgentMessageId): void => {
const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => {
const entries = queuedMirror.get(agent.id)
if (entries === undefined) return
entries.delete(id)
if (entries.size === 0) queuedMirror.delete(agent.id)
const index = entries.findIndex(entry =>
entry.message.id === id
&& (placement === undefined || entry.steering === (placement === 'steering')))
if (index !== -1) entries.splice(index, 1)
if (entries.length === 0) queuedMirror.delete(agent.id)
}
const disposers = [
ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage, placement) => {
ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => {
let entries = queuedMirror.get(agent.id)
if (entries === undefined) {
entries = new Map<AgentMessageId, { message: AgentMessage; steering: boolean }>()
entries = []
queuedMirror.set(agent.id, entries)
}
const steering = placement === 'steering'
entries.set(message.id, { message, steering })
entries.push({ message, steering })
broadcast({
type: 'session/queued',
sessionId: agent.id,
content: message.content,
source: message.source,
message,
steering,
})
}),
ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
retire(agent, message.id)
ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => {
retire(agent, message.id, placement)
}),
ctx.on('agent/inbox/discard', (agent: Agent, messages: AgentMessage[]) => {
ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => {
for (const message of messages) retire(agent, message.id)
}),
ctx.on('session/disposed', (session: Session) => {
@@ -717,6 +724,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const { sessionId, beforeSeq, maxMessages } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
// Everything below the resume above is synchronous: the page slice,
// the seq read, and the projection walk see one un-torn session state.
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
// Views are computed against the registry at pagination time; result
// pairing scans within the page only (message-boundary pagination keeps
@@ -725,11 +734,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
// Tail page carries the session-level todo projection over the FULL
// log (the page window may not contain the last todo/write; a paged
// client cannot reconstruct session-level state from it).
const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined
return ok(request, { events: entries, hasMore: page.hasMore, ...todos === undefined ? {} : { todos } })
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
// the one path that never needs a fresh projection baseline.
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
return ok(request, {
events: entries,
hasMore: page.hasMore,
...projections === undefined ? {} : { projections },
})
},
async models(request) {
@@ -841,8 +853,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
if (mode === 'steer') agent.steer({ content, source })
else agent.followup({ content, source })
const message: UserMessage = createUserMessage({ content, source })
if (mode === 'steer') agent.steer(message)
else agent.followup(message)
} catch (error: unknown) {
// 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) } })
@@ -1059,6 +1072,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, directoryError(error))
}
},
async openPath(request, signal) {
try {
const open = defaults.openPath
?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal))
await open(request.payload.path, signal)
return ok(request, { opened: true as const })
} catch (error: unknown) {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'path open was aborted',
details: {},
})
}
return err(request, {
code: 'internal',
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
},
},
commands: {
@@ -1086,12 +1121,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
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 } },
})
// Pure admission: the executor's durable command/run + command/done
// pair (broadcast on the mux stream) carries the outcome; the
// response reports whether the line resolved to a handler, plus the
// minted pairing id so the issuing client can correlate its request
// with the flow node the lifecycle events produce.
const execution = await commands.execute(found.agent, line, signal)
return ok(request, execution === undefined
? { matched: false }
: { matched: true, commandId: execution.commandId })
} 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: {} })
@@ -1163,12 +1201,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// 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()) {
for (const entry of entries) {
queue.push(frame({
type: 'session/queued',
sessionId,
content: entry.message.content,
source: entry.message.source,
message: entry.message,
steering: entry.steering,
}))
}
@@ -1194,10 +1231,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
if (event.type === 'session/title') {
// The accepted raw event is already in session.events, so the fold must find it.
queue.push(frame(titleFrame(session) as SessionTitleFrame))
}
}),
ctx.on('session/created', (session: Session) => {
subscribeSession(queue, session)

View File

@@ -4,10 +4,11 @@
*/
import { z } from 'zod'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
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'
import type { CommandDescriptor } from './commands.ts'
/** CommandDescriptor row of command.list. */
export const commandDescriptorSchema = z.object({
@@ -32,14 +33,12 @@ export const commandExecuteRequestSchema = z.object({
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>>
/** CommandId: one brand cast after shape validation (the only cast point in this domain). */
export const commandIdSchema = z.string().min(1) as unknown as z.ZodType<CommandId>
/** command.execute response value (matched=false carries no result). */
/** command.execute response value: pure admission — outcomes ride the logged
* lifecycle events; commandId (present exactly when matched) correlates with them. */
export const commandExecuteValueSchema = z.object({
matched: z.boolean(),
result: commandExecuteResultSchema.optional(),
commandId: commandIdSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>

View File

@@ -5,6 +5,7 @@
* together), so there is no agent-less surface on this wire.
*/
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcRequest, RpcResponse } from './rpc.ts'
@@ -22,12 +23,6 @@ export interface CommandDescriptor {
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 {
/**
@@ -38,11 +33,16 @@ export interface CommandsApi {
/**
* 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.
* without sending it to the model — pure admission semantics. matched=false
* when syntax or name does not resolve (the client falls back to its
* default sink). The handler's outcome does NOT ride the response: the host
* executor durably logs the lifecycle (`command/run`/`command/done`), which
* broadcasts on the mux stream and renders as a persistent flow node.
* `commandId` is present exactly when matched — the minted lifecycle
* pairing id, letting the issuing client correlate this acknowledgment
* with that flow node. The signal rides beside the request, never on the
* wire: the fetch carrier's request signal cancels the running handler.
*/
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
}

View File

@@ -23,11 +23,18 @@ export const askUserQuestionItemSchema = z.object({
multiSelect: z.boolean().optional(),
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
/** Unified message envelope carried by transient queue frames. */
const messageSchema = z.object({
id: z.string().min(1),
role: z.union([z.literal('system'), z.literal('user'), z.literal('assistant')]),
content: z.array(contentBlockSchema),
source: z.looseObject({ kind: z.string() }),
})
/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
// Non-empty by wire contract: the user-interaction service rejects empty
@@ -35,8 +42,10 @@ 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('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }),
// value stays wide: it already passed its unit's own schema on the host,
// and deep-validating here would import every domain's schema into the carrier.
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<MuxFrame>

View File

@@ -8,7 +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 { Message } 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'
@@ -35,9 +35,9 @@ export type ToolEventView =
export interface EventsApi {
/**
* All-session aggregated mux stream. On open, emits a subscribed control frame for every
* attached session followed by its optional latest title snapshot, then replays each
* session's still-pending approval/question requested frames (rpcId reused verbatim — the
* refresh-recovery baseline).
* attached session, then replays each session's still-pending approval/question requested
* frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the
* generic projection pair (history-tail projections block + session/projection frames).
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
* stream + refetch history.
*/
@@ -57,7 +57,6 @@ export interface EventsApi {
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number }
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
@@ -70,11 +69,20 @@ export type MuxFrame =
* refresh-recovery baseline as pending questions); queue clearing on cancel
* has no dedicated frame — clients fold it from the status flip.
* `steering` is the host's acceptance-time queue classification and remains
* authoritative in reconnect snapshots. `source` carries the prompt's rpcId
* authoritative in reconnect snapshots. `message.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: 'session/queued'; sessionId: SessionId; message: Message; steering: boolean }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the
* tool-view posture). `value` is the unit's schema-validated view output;
* `seq` is the unit's watermark at emission. Clients keep one generic
* per-session value store under higher-seq-wins, seeded by the history
* tail page's projections block.
*/
| { type: 'session/projection'; sessionId: SessionId; key: string; value: unknown; seq: number }
| { type: 'stream/error'; error: RpcError }
/**

View File

@@ -64,3 +64,12 @@ export const hostCreateDirectoryRequestSchema = z.object({
export const hostCreateDirectoryValueSchema = z.object({
path: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.createDirectory'>>>
/** host.openPath request payload. */
export const hostOpenPathRequestSchema = z.object({
path: z.string().min(1),
}) satisfies z.ZodType<Wire<RequestPayload<'host.openPath'>>>
/** host.openPath response value. */
export const hostOpenPathValueSchema = z.object({
opened: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'host.openPath'>>>

View File

@@ -89,4 +89,15 @@ export interface HostApi {
createDirectory(
request: RpcRequest<{ path: string; name: string }>,
): Promise<RpcResponse<{ path: string }>>
/**
* Open a filesystem path with the operating system's default application
* (Finder / Explorer / xdg-open hand-off). The browser carrier's
* prefix-wide trust fence covers this privileged method like every other
* `/api` request.
*/
openPath(
request: RpcRequest<{ path: string }>,
signal: AbortSignal,
): Promise<RpcResponse<{ opened: true }>>
}

View File

@@ -27,11 +27,11 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels, SessionsApi, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, DirectoryPickerKind, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { ApprovalResponsePayload } from './approvals.ts'

View File

@@ -28,6 +28,7 @@ export interface RpcMethodMap {
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']
'host.createDirectory': HostApi['createDirectory']
'host.openPath': HostApi['openPath']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']

View File

@@ -11,7 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
@@ -139,17 +139,22 @@ export const historyEntrySchema = z.object({
view: toolEventViewSchema.optional(),
}) satisfies z.ZodType<Wire<HistoryEntry>>
/** One todo item of the tail page's session-level projection (the todo/write payload shape). */
export const todoItemSchema = z.object({
content: z.string(),
status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]),
})
/**
* Projection baseline passthrough: `values` stays a wide record — each value
* was already parsed by its provider's own schema on the host side, and
* deep-validating here would import every domain's schema into the carrier.
*/
export const sessionProjectionsBlockSchema = z.object({
// -1 = empty log (the lastSeq convention of session/subscribed).
asOfSeq: z.number().int().min(-1),
values: z.record(z.string(), z.unknown()),
}) as unknown as z.ZodType<SessionProjectionsBlock>
/** session.history response value. */
/** session.history response value (projections rides the tail page only). */
export const sessionHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
todos: z.array(todoItemSchema).optional(),
projections: sessionProjectionsBlockSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
/** session.models request payload. */

View File

@@ -5,7 +5,10 @@
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
// The pure-type outlet: api/ is browser-importable, and the package root's
// cordis Context merge (via dsh-agent) must not enter client aggregates.
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
@@ -32,6 +35,23 @@ export interface HistoryEntry {
view?: ToolEventView
}
/**
* The projection baseline riding the history tail page: one synchronous cut
* over every registered projection unit, read from the registry's watermark
* cache. `asOfSeq` is the seq of the last committed event every value
* reflects — the window tail event seq (`-1` for an empty log, mirroring
* `session/subscribed.lastSeq`), directly comparable with
* `session/projection` frame seqs under the client's higher-seq-wins rule. A
* key absent from `values` means the capability is absent (its domain plugin
* is unmounted).
*/
export interface SessionProjectionsBlock {
/** Seq of the last event the values reflect; -1 for an empty log. */
asOfSeq: number
/** Whole current value per registered projection key. */
values: Partial<SessionProjectionMap>
}
/** Complete model target selected for one session. */
export interface ModelTarget {
/** Registered provider route. */
@@ -149,13 +169,14 @@ export interface SessionsApi {
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
* presenter produced one, evaluated against the registry at pagination time); the client
* rebuilds the surface from the events with the shared fold.
* The tail page (beforeSeq absent) also carries `todos` — the session's current todo
* projection (latest `todo/write` over the FULL log, independent of the page window) —
* so a paged client restores the plan without walking history; absent when the session
* never wrote one. Older pages omit it (the projection is session-level, not per-page).
* The tail page — and only the tail page — additionally carries `projections`
* when the deployment mounts the session-projection registry: every moment
* the client needs a fresh baseline already pulls the tail page, and
* loadOlder (the only beforeSeq path) is the only path that never needs one.
* A deployment without the registry serves histories without the block.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; todos?: TodoItem[] }>>
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>

View File

@@ -15,7 +15,7 @@ import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
import {
hostCreateDirectoryValueSchema, hostDescribeValueSchema,
hostListDirectoryValueSchema, hostPickDirectoryValueSchema,
hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
} from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
@@ -66,6 +66,7 @@ export interface IApiClient {
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.listDirectory'>>>
createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.createDirectory'>>>
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
}
workspace: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
@@ -105,6 +106,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
'host.createDirectory': hostCreateDirectoryValueSchema,
'host.openPath': hostOpenPathValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
@@ -314,6 +316,7 @@ export abstract class AbstractApiClient implements IApiClient {
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal),
createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal),
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
}
readonly workspace: IApiClient['workspace'] = {

View File

@@ -25,7 +25,8 @@ import {
} from '../api/sessions.schema.ts'
import {
hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
hostListDirectoryRequestSchema, hostPickDirectoryRequestSchema,
hostListDirectoryRequestSchema, hostOpenPathRequestSchema,
hostPickDirectoryRequestSchema,
} from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
@@ -65,6 +66,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r) => api.host.listDirectory(r) },
'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
'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) },

View File

@@ -0,0 +1,38 @@
/** Shared no-shell `execFile` runner for native host dialogs and openers. */
import { execFile } from 'node:child_process'
/** Testable command boundary; native implementations never invoke a shell. */
export type NativeCommandRunner = (
command: string,
args: readonly string[],
signal: AbortSignal,
) => Promise<{ stdout: string; stderr: string }>
/**
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
* @param command - executable path or PATH name.
* @param args - argv (never a shell string).
* @param signal - caller/connection lifetime; abort terminates the child.
* @returns captured stdout/stderr on exit 0.
*/
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
new Promise((resolve, reject) => {
execFile(
command,
[...args],
{ encoding: 'utf8', signal, windowsHide: true },
(error, stdout, stderr) => {
if (error !== null) {
const failure = Object.assign(new Error(error.message, { cause: error }), {
code: error.code,
stdout,
stderr,
})
reject(failure)
return
}
resolve({ stdout, stderr })
},
)
})

View File

@@ -0,0 +1,53 @@
/** Cross-platform open-with-default-application used by the local GUI carrier. */
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
/** Testable command boundary; native implementations never invoke a shell. */
export type PathOpenerRunner = NativeCommandRunner
/** Injectable platform facts for deterministic adapter tests. */
export interface PathOpenerInternals {
platform?: NodeJS.Platform
run?: PathOpenerRunner
}
/** PowerShell single-quoted literal (doubles embedded quotes). */
function powershellLiteral(path: string): string {
return `'${path.replace(/'/g, "''")}'`
}
/**
* Open a filesystem path with the operating system's default application.
* @param path - absolute or host-resolvable path (caller owns resolution).
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
*/
export async function openNativePath(
path: string,
signal: AbortSignal,
internals: PathOpenerInternals = {},
): Promise<void> {
const platform = internals.platform ?? process.platform
const run = internals.run ?? runNativeCommand
if (platform === 'darwin') {
await run('open', [path], signal)
return
}
if (platform === 'win32') {
await run('powershell.exe', [
'-NoProfile',
'-Command',
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
], signal)
return
}
if (platform === 'linux') {
await run('xdg-open', [path], signal)
return
}
throw new Error(`native path opener is unsupported on ${platform}`)
}

View File

@@ -1,3 +1,4 @@
import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
/**
* Command/skill RPC handlers and the two new frames over createApiProxy:
* command.list serves the addressed agent's effective catalog (missing
@@ -10,10 +11,10 @@
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 AgentRegistry, {} from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId, UserMessage } 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'
@@ -115,8 +116,16 @@ describe('command.execute', () => {
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(value).toMatchObject({ matched: true })
expect(value.commandId).toBeTruthy()
expect(received).toBe(' ship it')
// Pure admission on the wire: the outcome rides the durably logged
// lifecycle pair instead of the response.
const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
expect(lifecycle).toMatchObject([
{ type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } },
{ type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } },
])
})
it('returns matched:false when syntax or name does not resolve', async () => {
@@ -238,9 +247,10 @@ describe('host/commands-changed frame', () => {
})
/** Build one frozen inbox message for the live `agent/inbox/*` events. */
function inboxMessage(id: string, text: string, rpcId?: string): AgentMessage {
return Object.freeze({
id: AgentMessageId(id),
function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
return freezeMessage({
id: MessageId(id),
role: 'user',
content: [{ type: 'text' as const, text }],
source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
})
@@ -263,8 +273,8 @@ describe('session/queued frames', () => {
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 },
{ type: 'session/queued', sessionId: agent.id, message: queued, steering: false },
{ type: 'session/queued', sessionId: agent.id, message: steering, steering: true },
])
// A fresh mux connection replays the still-pending entries as its baseline.
@@ -282,8 +292,8 @@ describe('session/queued frames', () => {
const steering = inboxMessage('m-4', 'x', 'r-1')
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
ctx.emit('agent/inbox/dequeue', agent, queued)
ctx.emit('agent/inbox/dequeue', agent, steering)
ctx.emit('agent/inbox/dequeue', agent, queued, 'queued')
ctx.emit('agent/inbox/dequeue', agent, steering, 'steering')
const abort = new AbortController()
const frames = await collect<MuxFrame>(
@@ -291,6 +301,24 @@ describe('session/queued frames', () => {
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
})
it('retires the matching placement when one message identity is queued and steering', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const repeated = inboxMessage('m-repeat', 'same prompt')
ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued')
ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering')
ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued')
ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering')
const abort = new AbortController()
const frames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-repeat'), payload: {} }, abort.signal), 2, abort)
expect(frames.filter(f => f.type === 'session/queued')).toEqual([
{ type: 'session/queued', sessionId: agent.id, message: repeated, steering: false },
])
})
it('retires mirror entries on a batch discard (cancel path)', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
@@ -306,6 +334,6 @@ describe('session/queued frames', () => {
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 })
expect(remaining[0]).toMatchObject({ message: survivor })
})
})

View File

@@ -0,0 +1,185 @@
/**
* Projection carrier paths of the host ApiProxy: the history tail page's
* projections block reads the registry's watermark snapshot (asOfSeq = last
* event seq, one consistent cut); loadOlder pages never carry the block; a
* composition without the registry serves histories without it; a disposed
* registration's key leaves subsequent responses; and every unit change is
* pushed to mux consumers as a session/projection frame minted here.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { z } from 'zod'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
'test/last-user': { text: string } | null
}
}
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`proj-${String(nextRpc++)}`), payload }
}
/** Whole-value unit folding the latest user/message text; null before the first. */
type LastUserState = { text: string } | null
const lastUserUnit = (): ProjectionDefinition<'test/last-user', LastUserState> => ({
key: 'test/last-user',
schema: z.union([z.object({ text: z.string() }), z.null()]),
init: () => null,
apply: (state, event) => (event.type === 'user/message'
? { text: (event.data.content[0] as { text?: string }).text ?? '' }
: state),
view: state => state,
stateVersion: 1,
})
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return { ctx, session }
}
/** Append `count` user messages so the log has paginable message boundaries. */
function seedMessages(session: Session, count: number): void {
for (let i = 0; i < count; i++) {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `m${i}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
}
const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
describe('session.history projections block', () => {
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 3)
const response = await api(ctx).sessions.history(request({ sessionId: session.id }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
const { events, projections } = response.result.value
expect(projections).toBeDefined()
expect(projections?.asOfSeq).toBe(session.seq - 1)
expect(projections?.values['test/last-user']).toEqual({ text: 'm2' })
// asOfSeq IS the window tail: the last served event carries it.
expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
})
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 5)
const older = await api(ctx).sessions.history(request({ sessionId: session.id, beforeSeq: 3, maxMessages: 2 }))
expect(older.result.ok).toBe(true)
if (!older.result.ok) throw new Error('unreachable')
expect('projections' in older.result.value).toBe(false)
})
it('serves no block when the composition has no projection registry', async () => {
const { ctx, session } = await harness(false)
seedMessages(session, 2)
const response = await api(ctx).sessions.history(request({ sessionId: session.id }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect('projections' in response.result.value).toBe(false)
})
it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => {
const { ctx, session } = await harness(true)
const dispose = ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 1)
const proxy = api(ctx)
const before = await proxy.sessions.history(request({ sessionId: session.id }))
if (!before.result.ok) throw new Error('unreachable')
expect(before.result.value.projections?.values['test/last-user']).toEqual({ text: 'm0' })
dispose()
const after = await proxy.sessions.history(request({ sessionId: session.id }))
if (!after.result.ok) throw new Error('unreachable')
// The registry is still mounted, so the block itself stays (asOfSeq cut
// with zero keys); the disposed key reads as capability absence.
expect(after.result.value.projections?.asOfSeq).toBe(session.seq - 1)
expect(after.result.value.projections?.values).toEqual({})
})
})
describe('session/projection push frame', () => {
/** Drain frames until `count` session/projection frames arrived. */
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
const frames: MuxFrame[] = []
for await (const envelope of iterable) {
frames.push(envelope.payload)
if (frames.filter(f => f.type === 'session/projection').length >= count) abort.abort()
}
return frames
}
it('broadcasts a frame per changed unit with the causing seq, and none for same-reference applies', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const proxy = api(ctx)
// The gateway's onChanged subscription lives in an inject child whose
// fiber activates asynchronously; yield until it lands before appending.
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-proj-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 2, abort)
seedMessages(session, 1)
// Same-reference apply: turn/start does not concern the unit — no frame.
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
seedMessages(session, 1)
const frames = await collected
const pushes = frames.filter(
(f): f is Extract<MuxFrame, { type: 'session/projection' }> => f.type === 'session/projection',
)
expect(pushes).toEqual([
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 },
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 },
])
// Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible).
const tail = await proxy.sessions.history(request({ sessionId: session.id }))
if (!tail.result.ok) throw new Error('unreachable')
expect(tail.result.value.projections?.asOfSeq).toBe(pushes.at(-1)?.seq)
})
it('emits no projection frames when the composition has no registry', async () => {
const { ctx, session } = await harness(false)
const proxy = api(ctx)
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-noproj-mux'), payload: {} }, abort.signal)
const frames: MuxFrame[] = []
const drained = (async () => {
for await (const envelope of stream) {
frames.push(envelope.payload)
if (frames.filter(f => f.type === 'session/event').length >= 2) abort.abort()
}
})()
seedMessages(session, 2)
await drained
expect(frames.some(f => f.type === 'session/projection')).toBe(false)
})
})

View File

@@ -14,9 +14,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { CallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -88,16 +88,35 @@ describe('mux live view computation', () => {
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-call-only'), content: [{ type: 'text', text: rawResult }], isError: false }, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-call-only'),
content: [{ type: 'text', text: rawResult }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-gen'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-gen'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const frames = await collected
const events = frames.filter(f => f.type === 'session/event')
const byCall = new Map(events
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
.map(f => [`${f.event.type}:${(f.event.data as { callId: string }).callId}`, f]))
.map(f => [
`${f.event.type}:${f.event.type === 'tool/call'
? f.event.data.callId
: (f.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
f,
]))
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
@@ -130,15 +149,44 @@ describe('mux live view computation', () => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
// meta rides through to presentResult's ToolResult (the spread arm).
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-term'), content: [{ type: 'text', text: 'ok' }], isError: false, meta: { n: 1 } }, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-term'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
meta: { n: 1 },
}, { surfaceOp: 'append' })
// Unpaired result: no tool/call with this id anywhere in the page.
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-orphan'), content: [{ type: 'text', text: 'x' }], isError: false }, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-orphan'),
content: [{ type: 'text', text: 'x' }],
isError: false,
}),
}, { surfaceOp: 'append' })
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-bad'), content: [{ type: 'text', text: 'y' }], isError: false }, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-bad'),
content: [{ type: 'text', text: 'y' }],
isError: false,
}),
}, { surfaceOp: 'append' })
// Presenterless tool: pairing succeeds but presentResult is absent.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-plain'), content: [{ type: 'text', text: 'z' }], isError: false }, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-plain'),
content: [{ type: 'text', text: 'z' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } })
expect(response.result.ok).toBe(true)
@@ -146,7 +194,12 @@ describe('mux live view computation', () => {
const entries = response.result.value.events
const byKey = new Map(entries
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
.map(entry => [`${entry.event.type}:${(entry.event.data as { callId: string }).callId}`, entry]))
.map(entry => [
`${entry.event.type}:${entry.event.type === 'tool/call'
? entry.event.data.callId
: (entry.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
entry,
]))
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
@@ -154,39 +207,6 @@ describe('mux live view computation', () => {
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('tail page carries the full-log todo projection; older pages and todo-less sessions omit it', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
// Superseded write early in the log, latest write later; enough messages to page.
session.append('todo/write', { todos: [{ content: 'old', status: 'pending' }] })
for (let turn = 0; turn < 6; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: `q${turn}` }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('assistant/message', { turn, step: 0, content: [{ type: 'text', text: `a${turn}` }], provenance: { provider: 'p', model: 'm' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
session.append('todo/write', { todos: [{ content: 'current', status: 'in_progress' }] })
// Tail page limited to 2 messages: the latest todo/write may or may not sit
// in the window — the projection must come from the FULL log either way.
const tail = await api.sessions.history({ rpcId: RpcId('t-todos'), payload: { sessionId: session.id, maxMessages: 2 } })
if (!tail.result.ok) throw new Error('history failed')
expect(tail.result.value.todos).toEqual([{ content: 'current', status: 'in_progress' }])
// An older page omits the projection (session-level, tail-page-only).
const boundary = tail.result.value.events[0]?.event.seq ?? 0
const older = await api.sessions.history({ rpcId: RpcId('t-todos-2'), payload: { sessionId: session.id, beforeSeq: boundary, maxMessages: 2 } })
if (!older.result.ok) throw new Error('older failed')
expect('todos' in older.result.value).toBe(false)
// A session with no todo/write anywhere omits the field.
const bare = ctx.sessions.create()
ctx.agents.register({ id: bare.id, session: bare, status: 'idle', ctx } as Agent)
const bareTail = await api.sessions.history({ rpcId: RpcId('t-todos-3'), payload: { sessionId: bare.id } })
if (!bareTail.result.ok) throw new Error('bare failed')
expect('todos' in bareTail.result.value).toBe(false)
})
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
@@ -221,7 +241,14 @@ describe('mux live view computation', () => {
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// The turn/end above cleared the live table; pairing must fall back to
// scanning the session's in-memory events.
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-late'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-late'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const frames = await collected
const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result')

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -47,10 +47,10 @@ function stubAgent(session: Session): Agent {
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
followup: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject: () => AgentMessageId('stub'),
send: () => AgentMessageId('stub'),
followup: () => {},
steer: () => {},
inject: () => {},
send: () => {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -60,6 +60,7 @@ function stubAgent(session: Session): Agent {
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -102,6 +103,7 @@ async function harness(
model: 'test-model',
cwd: workspaceRoot,
workspaceRoot,
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
})
return { api, ctx, storageDomain, workspaceRoot }
}
@@ -204,6 +206,30 @@ describe('host.listDirectory / host.createDirectory', () => {
})
})
describe('host.openPath', () => {
it('opens through the injected native boundary', async () => {
const opened: string[] = []
const { api } = await harness(undefined, undefined, {
openPath: async (path) => { opened.push(path) },
})
expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
.toEqual({ ok: true, value: { opened: true } })
expect(opened).toEqual(['/tmp/a.txt'])
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
const { api } = await harness(undefined, undefined, {
openPath: (_path, signal) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
})
const abort = new AbortController()
const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal)
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
})
describe('workspace.create', () => {
it('serializes concurrent names and rejects the duplicate', async () => {
const { api, workspaceRoot } = await harness()

View File

@@ -52,6 +52,7 @@ function scriptedApi(overrides: {
pickDirectory: r => ok(r, { path: null }),
listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [] }),
createDirectory: r => ok(r, { path: '/t/new' }),
openPath: r => ok(r, { opened: true as const }),
...overrides.host,
},
workspace: {

View File

@@ -1,3 +1,4 @@
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { describe, expect, it, vi } from 'vitest'
import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts'
import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts'
@@ -25,10 +26,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
},
async history(request) {
if (request.payload.sessionId === ('with-todos' as never)) {
if (request.payload.sessionId === ('with-projections' as never)) {
return {
rpcId: request.rpcId,
result: { ok: true, value: { events: [], hasMore: false, todos: [{ content: 'current', status: 'in_progress' as const }] } },
result: { ok: true, value: { events: [], hasMore: false, projections: { asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' as const }] } } } },
}
}
return {
@@ -86,6 +87,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async createDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } }
},
async openPath(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
},
},
workspace: {
async list(request) {
@@ -127,7 +131,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
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: true, commandId: CommandId('cmd-x') } } }
}
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
},
@@ -164,10 +168,14 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(response.rpcId).toMatch(/[0-9a-f-]{36}/)
})
it('carries the tail-page todos projection through the wire schema (Zod must not strip it)', async () => {
const response = await client().sessions.history({ sessionId: 'with-todos' as never })
it('carries the tail-page projections block through the wire schema (Zod must not strip it)', async () => {
const response = await client().sessions.history({ sessionId: 'with-projections' as never })
expect(response.result.ok).toBe(true)
if (response.result.ok) expect(response.result.value.todos).toEqual([{ content: 'current', status: 'in_progress' }])
if (response.result.ok) {
expect(response.result.value.projections).toEqual(
{ asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' }] } },
)
}
})
it('carries a business error as 200 + error result', async () => {
@@ -224,12 +232,24 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } })
})
it('round-trips host.openPath through the wire form', async () => {
const api = fakeApi()
let opened: string | undefined
api.host.openPath = async (request) => {
opened = request.payload.path
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
}
const response = await client(api).host.openPath({ path: '/tmp/a.txt' })
expect(opened).toBe('/tmp/a.txt')
expect(response.result).toEqual({ ok: true, value: { opened: 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' } } })
expect(hit.result).toEqual({ ok: true, value: { matched: true, commandId: 'cmd-x' } })
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 })

View File

@@ -0,0 +1,82 @@
type ExecFileCallback = (
error: (Error & { code?: string | number }) | null,
stdout: string,
stderr: string,
) => void
type ExecFileMock = (
command: string,
args: readonly string[],
options: { encoding: string; signal: AbortSignal; windowsHide: boolean },
callback: ExecFileCallback,
) => void
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() }))
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { describe, expect, it, vi } from 'vitest'
import { openNativePath, type PathOpenerRunner } from '../src/native-path-opener.ts'
const signal = () => new AbortController().signal
describe('native path opener', () => {
it('opens with macOS open(1)', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/Users/test/file.txt', signal(), { platform: 'darwin', run })
expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal))
})
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
expect(run).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\o''reilly.txt'"],
expect.any(AbortSignal),
)
})
it('opens with Linux xdg-open', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal))
})
it('rejects unsupported platforms', async () => {
await expect(openNativePath('/x', signal(), { platform: 'freebsd' as NodeJS.Platform }))
.rejects.toThrow('unsupported on freebsd')
})
it('uses the current process platform when no platform override is supplied', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/tmp/platform-default.txt', signal(), { run })
const expected = process.platform === 'win32'
? 'powershell.exe'
: process.platform === 'linux'
? 'xdg-open'
: 'open'
expect(run.mock.calls[0]?.[0]).toBe(expected)
})
it('runs the default command adapter without a shell and preserves command failures', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, '', '')
})
await openNativePath('/tmp/default.txt', signal(), { platform: 'darwin' })
const [command, args, options] = execFileMock.mock.calls[0]!
expect(command).toBe('open')
expect(args).toEqual(['/tmp/default.txt'])
expect(options.encoding).toBe('utf8')
expect(options.windowsHide).toBe(true)
expect(options.signal).toBeInstanceOf(AbortSignal)
const commandError = Object.assign(new Error('open failed'), { code: 1 })
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(commandError, 'partial output', 'failure details')
})
await expect(openNativePath('/tmp/missing.txt', signal(), { platform: 'darwin' })).rejects.toMatchObject({
message: 'open failed', cause: commandError, code: 1,
stdout: 'partial output', stderr: 'failure details',
})
})
})

View File

@@ -123,9 +123,19 @@ describe('sessions domain schemas', () => {
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 } })
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()
expect(() => sessionEventSchema.parse({
type: 'user/message',
seq: -1,
time: 1,
data: {},
})).toThrow()
})
it('validates the per-method request/value pairs', () => {
@@ -300,10 +310,13 @@ describe('commands domain schemas', () => {
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()
// Pure admission: matched plus the optional lifecycle pairing id
// (outcomes ride the logged lifecycle events, never this response).
expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' }))
.toEqual({ matched: true, commandId: 'cmd-1' })
expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true })
expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow()
expect(() => commandExecuteValueSchema.parse({})).toThrow()
})
})
@@ -328,23 +341,21 @@ describe('events frame schemas', () => {
const frames = [
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
{ type: 'session/title', sessionId: 's', title: 'Durable title', eventSeq: 2, updatedAt: 3 },
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
{ 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: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false },
{ type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
for (const invalid of [
{ type: 'session/title', sessionId: 's', title: '', eventSeq: 0, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: -1, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0.5, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: 'now' },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: Number.NaN },
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
})
@@ -354,9 +365,9 @@ describe('events frame schemas', () => {
})
it('rejects a queued frame missing its members', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: 'x', source: { kind: 'user' } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: { kind: 'user' } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: {}, steering: false })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow()
})
it('accepts every host frame branch', () => {

View File

@@ -33,7 +33,7 @@
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-title/session-title"
"path": "../../session-projection/session-projection"
},
{
"path": "../../skill/skill"

View File

@@ -0,0 +1,38 @@
/** Shared no-shell `execFile` runner for native host dialogs and openers. */
import { execFile } from 'node:child_process'
/** Testable command boundary; native implementations never invoke a shell. */
export type NativeCommandRunner = (
command: string,
args: readonly string[],
signal: AbortSignal,
) => Promise<{ stdout: string; stderr: string }>
/**
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
* @param command - executable path or PATH name.
* @param args - argv (never a shell string).
* @param signal - caller/connection lifetime; abort terminates the child.
* @returns captured stdout/stderr on exit 0.
*/
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
new Promise((resolve, reject) => {
execFile(
command,
[...args],
{ encoding: 'utf8', signal, windowsHide: true },
(error, stdout, stderr) => {
if (error !== null) {
const failure = Object.assign(new Error(error.message, { cause: error }), {
code: error.code,
stdout,
stderr,
})
reject(failure)
return
}
resolve({ stdout, stderr })
},
)
})

View File

@@ -1,13 +1,9 @@
/** Cross-platform native single-directory chooser behind the dialog backend's capability. */
import { execFile } from 'node:child_process'
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
/** Testable command boundary; native implementations never invoke a shell. */
export type DirectoryPickerRunner = (
command: string,
args: readonly string[],
signal: AbortSignal,
) => Promise<{ stdout: string; stderr: string }>
export type DirectoryPickerRunner = NativeCommandRunner
/** Injectable platform facts for deterministic adapter tests. */
export interface DirectoryPickerInternals {
@@ -15,27 +11,6 @@ export interface DirectoryPickerInternals {
run?: DirectoryPickerRunner
}
const runCommand: DirectoryPickerRunner = (command, args, signal) =>
new Promise((resolve, reject) => {
execFile(
command,
[...args],
{ encoding: 'utf8', signal, windowsHide: true },
(error, stdout, stderr) => {
if (error !== null) {
const failure = Object.assign(new Error(error.message, { cause: error }), {
code: error.code,
stdout,
stderr,
})
reject(failure)
return
}
resolve({ stdout, stderr })
},
)
})
function outputPath(stdout: string): string | null {
const path = stdout.replace(/[\r\n]+$/, '')
return path === '' ? null : path
@@ -72,7 +47,7 @@ export async function pickNativeDirectory(
internals: DirectoryPickerInternals = {},
): Promise<string | null> {
const platform = internals.platform ?? process.platform
const run = internals.run ?? runCommand
const run = internals.run ?? runNativeCommand
if (platform === 'darwin') {
try {