feat(gui): host goal handlers and slash command dispatch on web prompts

- web host boots CommandService + command-goal; session.prompt intercepts
  a lone text block starting with '/' and executes it as a command
  (unknown -> RPC unknown-command, usage error -> RPC command-error, so
  the composer restores the draft and shows the error strip) instead of
  sending it to the model — symmetric with the ACP adapter
- prompt response carries command result text on the wire for future UI
- api-proxy also lands the host-side goals RPC handlers
This commit is contained in:
_Kerman
2026-07-22 20:52:11 +08:00
parent 923535fa7a
commit 158b8a8ead
14 changed files with 478 additions and 4 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-22-web-slash-command-dispatch.md: 11f6c696c7947531ce147f7bf9e48a6f9bfe2a92
2026-07-22-web-slash-command-dispatch.zh.md: b08835112926f03f817b3b06e7594f3e9d2e237e

View File

@@ -0,0 +1,39 @@
# Agent Note: Web slash-command dispatch
Status: implemented
English | [中文](2026-07-22-web-slash-command-dispatch.zh.md)
## Problem
The [human `/goal` command](2026-07-19-human-goal-command.md) shipped with two dispatch points: the TUI and ACP adapters intercept leading-`/` prompts and execute them through the command registry without a model turn. The web UI had no interception anywhere on its path — composer, client runtime, RPC, and host all passed the text through — so `/goal fix the flaky test` reached the model as an ordinary user message. The command cost a model turn, produced no deterministic state change, and could be reinterpreted, while the web host composition never even mounted the command registry or the `/goal` producer.
## Decision
The web host dispatches slash commands at its own adapter boundary, symmetric with ACP: inside the api-proxy `sessions.prompt` handler in `packages/host/runtime/src/api-proxy.ts`, before `agent.send`/`agent.steer`. `bootHost` mounts `CommandService` and `command-goal` right after the goal stack, so the registry and producer share the host composition's lifecycle.
A prompt whose content is exactly one text block starting with `/` is the command candidate. The web composer only ever sends that shape, and multi-block content is never flattened into a command line. Dispatch is mode-agnostic: commands consume no turn, so queue and steer execute identically and neither reaches the agent.
The execution outcome maps onto the RPC result the composer choreography already understands. A successful command returns ok with `{ accepted: true, command: { kind: 'success', text? } }` — the draft stays cleared. A usage or state error (bare `/goal edit`, a redundant `/goal pause`) returns an RPC error with the new code `command-error`, and an unrecognized name returns `unknown-command`; both make the client restore the composer's draft and show the message on the error strip, which is the right UX for a malformed command. The two codes are new rows in `RpcErrorDetailsMap` with matching error-schema branches, and the `session.prompt` response value gains the optional command slot in both the signature layer and the zod schema.
The success text travels on the wire but the web UI does not render it yet; the state change — the goal bar appearing, a paused goal resuming — is the feedback. Handler defects still propagate out of `commands.execute` and become carrier-level 500s, matching the api-proxy rule that implementations never throw business errors.
## Testing
`packages/host/runtime/tests/api-proxy-command.spec.ts` mounts the real command registry, agent registry, goal service, and `/goal` producer against a structural idle agent whose `send`/`steer` calls are recorded. It covers `/goal <objective>` creating the goal with the command slot carried and no model turn, mode-agnostic dispatch under `steer`, the `unknown-command` (with and without trailing input) and `command-error` RPC errors, a registered command whose success carries no text, a non-command prompt reaching `agent.send` unchanged, and degenerate shapes — multi-block content, an empty array, a single non-text block — never being treated as a command. The existing `rpc-schemas.spec.ts` gates the extended wire shape.
## Alternatives considered
- **Intercept in the browser client** — rejected because the command registry and goal domain live in the host process; the client has no plugin runtime, and duplicating command ownership client-side would drift from the host's composition.
- **Render command output as a synthetic assistant message** — rejected because fabricating a model-visible event would violate the model-visible ⟺ logged rule and invent a second audit record; the wire carries the text for a future dedicated surface instead.
- **Dispatch only in queue mode** — rejected because commands consume no turn, so mode is meaningless to them; ACP likewise executes commands outside its prompt-turn machinery.
- **Flatten multi-block content into a command line like ACP** — rejected because the web composer sends exactly one text block; a lossy flattening path would have no caller.
## Consequences
- `/goal` and any future registered command work from the web composer without a model turn; unknown or malformed commands restore the draft with an error instead of reaching the model.
- The RPC error vocabulary gains `command-error` and `unknown-command`, and `session.prompt` responses may carry a command slot.
- Command success text is on the wire but unrendered in the web UI; a dedicated output surface remains deferred.
- Multi-block prompts are never command candidates, so richer composer content cannot accidentally dispatch.
- Commands dispatched in the web host run under a fresh, never-aborted AbortController: an async command (the registry permits them) cannot be cancelled from the UI, and `session.cancel` does not reach it (ACP keeps the controller on the session record for exactly this).
- The single-block predicate rejects any lone text block starting with `/` that is not a registered command (e.g. `/etc/hosts`, `/Goal`) as `unknown-command` rather than letting it reach the model — deliberate and symmetric with ACP, mitigated by the draft restore.

View File

@@ -0,0 +1,39 @@
# Agent Note: Web 斜杠命令分发
Status: implemented
[English](2026-07-22-web-slash-command-dispatch.md) | 中文
## 问题
[面向人类的 `/goal` 命令](2026-07-19-human-goal-command.md)交付时有两个分发点TUI 与 ACP 适配器拦截以 `/` 开头的提示词通过命令注册表执行而不消耗模型轮次。Web UI 的整条路径——输入框、客户端运行时、RPC 与宿主——都没有任何拦截,因此 `/goal fix the flaky test` 会作为普通用户消息到达模型。该命令消耗一次模型轮次、不产生确定性的状态改变、还可能被重新解释,而 Web 宿主组合甚至没有挂载命令注册表和 `/goal` 生产方。
## 决策
Web 宿主在自己的适配器边界分发斜杠命令,与 ACP 对称:在 `packages/host/runtime/src/api-proxy.ts` 的 api-proxy `sessions.prompt` 处理器内、`agent.send`/`agent.steer` 之前。`bootHost` 在目标栈之后紧接着挂载 `CommandService``command-goal`,使注册表与生产方共享宿主组合的生命周期。
内容恰好是一个以 `/` 开头的文本块的提示词才是命令候选。Web 输入框只会发送这种形态,多块内容绝不会被拍平成命令行。分发与模式无关:命令不消耗轮次,因此 queue 与 steer 的执行完全一致,且都不会到达 agent。
执行结果映射到输入框编排逻辑已经理解的 RPC 结果上。命令成功时返回 ok携带 `{ accepted: true, command: { kind: 'success', text? } }`——草稿保持已清空状态。用法或状态错误(单独的 `/goal edit`、多余的 `/goal pause`)返回新错误码 `command-error` 的 RPC 错误,未识别的名称返回 `unknown-command`;两者都会让客户端恢复输入框草稿并在错误条上显示消息,这正是畸形命令应有的 UX。这两个错误码是 `RpcErrorDetailsMap` 中的新行,并配有对应的错误 schema 分支;`session.prompt` 的响应值在签名层与 zod schema 中都增加了可选的 command 槽位。
成功文本会在线路上传输,但 Web UI 暂时不渲染它;状态改变——目标栏出现、已暂停目标恢复——就是反馈。处理器缺陷仍会传播出 `commands.execute` 并成为承载层 500这符合 api-proxy 的规则:实现绝不抛出业务错误。
## 测试
`packages/host/runtime/tests/api-proxy-command.spec.ts` 针对一个记录了 `send`/`steer` 调用的结构性空闲 agent挂载真实的命令注册表、agent 注册表、目标服务与 `/goal` 生产方。它覆盖:`/goal <objective>` 创建目标、携带 command 槽位且不经过模型轮次;`steer` 下与模式无关的分发;`unknown-command`(带与不带尾随输入)与 `command-error` RPC 错误;成功但不带文本的已注册命令;非命令提示词原样到达 `agent.send`;退化形态——多块内容、空数组、单个非文本块——绝不会被当作命令。现有的 `rpc-schemas.spec.ts` 对扩展后的线路形态进行门禁。
## 考虑过的替代方案
- **在浏览器客户端拦截**——不予采纳,因为命令注册表与目标领域位于宿主进程;客户端没有插件运行时,在客户端复制命令所有权会与宿主的组合发生偏差。
- **把命令输出渲染为合成的助手消息**——不予采纳,因为凭空制造模型可见事件会违反“模型可见 ⟺ 已记录”规则,并引入第二份审计记录;线路上携带文本,留待未来的专用表面渲染。
- **仅在 queue 模式下分发**——不予采纳因为命令不消耗轮次模式对它们没有意义ACP 同样在其提示词轮次机制之外执行命令。
- **像 ACP 那样把多块内容拍平成命令行**——不予采纳,因为 Web 输入框恰好只发送一个文本块;有损的拍平路径不会有调用方。
## 后果
- `/goal` 以及未来注册的任何命令都可以在 Web 输入框中使用且不消耗模型轮次;未知或畸形命令会恢复草稿并报错,而不是到达模型。
- RPC 错误词汇表新增 `command-error``unknown-command``session.prompt` 响应可以携带 command 槽位。
- 命令成功文本已在线路上但尚未在 Web UI 渲染;专用输出表面仍然延期。
- 多块提示词绝不会成为命令候选,因此更丰富的输入框内容不会意外触发分发。
- Web 宿主分发的命令运行在一个新建的、永远不会被中止的 AbortController 下:异步命令(注册表允许异步命令)无法从 UI 取消,`session.cancel` 也触及不到它ACP 正是为此把 controller 保存在会话记录上)。
- 单块判定意味着任何以 `/` 开头但不是已注册命令的单独文本块(如 `/etc/hosts``/Goal`)都会以 `unknown-command` 被拒绝,而不会到达模型——这是有意为之,与 ACP 对称,并由草稿恢复机制缓解。

View File

@@ -611,7 +611,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef
Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md)
Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/src/index.ts)
Source: [`packages/goal/goal/src/index.ts:147`](../../packages/goal/goal/src/index.ts)
## `ctx.invariants` — `InvariantService`

View File

@@ -35,6 +35,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -32,6 +32,10 @@ export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'session-not-found': { sessionId: SessionId }
'agent-busy': { reason: string }
/** A known slash command reported a usage/state error; the message is the command's own text. */
'command-error': {}
/** A leading-/ prompt named no registered command; the message names the token. */
'unknown-command': {}
'internal': {}
}

View File

@@ -94,9 +94,13 @@ export const sessionPromptRequestSchema = z.object({
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
/** session.prompt response value. */
/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */
export const sessionPromptValueSchema = z.object({
accepted: z.literal(true),
command: z.object({
kind: z.literal('success'),
text: z.string().optional(),
}).optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
/** session.cancel request payload. */

View File

@@ -64,9 +64,16 @@ export interface SessionsApi {
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
/**
* Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer.
* A prompt whose content is exactly one text block starting with '/' is a slash command: the host
* executes it through the command registry (mode-agnostic) and it is never sent to the model. A
* successful command returns ok with the command slot (its success text, when the command produced
* one — carried for future rendering; the state change is the feedback). A usage/state error is an
* RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
*/
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
Promise<RpcResponse<{ accepted: true }>>
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>

View File

@@ -31,11 +31,14 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
it('rejects a known code with missing details', () => {
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
})
})
@@ -103,6 +106,11 @@ describe('sessions domain schemas', () => {
expect(prompt.mode).toBe('queue')
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
// The command slot appears only when the prompt dispatched a slash command.
const dispatched = sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success', text: 'Goal set' } })
expect(dispatched.command?.text).toBe('Goal set')
expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' })
expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow()
expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 })

View File

@@ -40,7 +40,11 @@
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",

View File

@@ -11,8 +11,10 @@ import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-commands'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { GoalView } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -104,6 +106,17 @@ function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
/**
* Slash-command candidate: the web composer sends exactly one text block, so
* only that exact shape dispatches; multi-block content is never flattened.
*/
function commandCandidate(content: ContentBlock[]): string | undefined {
const [first, ...rest] = content
if (first === undefined || rest.length > 0) return undefined
if (first.type !== 'text' || !first.text.startsWith('/')) return undefined
return first.text
}
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
return {
@@ -208,6 +221,22 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
return undefined
}
/** Project a server-side GoalView into the wire GoalView shape. */
function goalView(g: import('@deepseek-ai/dsh-goal').GoalView): GoalView {
return {
id: g.id,
revision: g.revision,
objective: g.objective,
phase: g.phase,
...(g.blockedReason !== undefined ? { blockedReason: g.blockedReason } : {}),
maxGoalRounds: g.maxGoalRounds,
roundsStarted: g.roundsStarted,
createdAt: g.createdAt,
updatedAt: g.updatedAt,
activation: g.activation,
}
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
@@ -318,6 +347,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const agent = found.agent
// Host-side slash-command dispatch (symmetric with the ACP adapter): a
// leading-/ single-text-block prompt executes through the command
// registry instead of reaching the model. Commands are mode-agnostic,
// so queue and steer dispatch identically.
const commandLine = commandCandidate(content)
if (commandLine !== undefined) {
// Unary handlers carry no request signal; the dispatch owns a fresh
// one (commands here are synchronous mutations, so nothing aborts it).
const result = await ctx.commands.execute(agent, commandLine, new AbortController().signal)
if (result === undefined) {
const space = commandLine.search(/\s/u)
const token = space === -1 ? commandLine : commandLine.slice(0, space)
return err(request, { code: 'unknown-command', message: `unknown command: ${token}`, details: {} })
}
// Usage/state errors travel as RPC errors so the client restores the
// composer's draft and shows the message on its error strip.
if (result.kind === 'error') {
return err(request, { code: 'command-error', message: result.text, details: {} })
}
return ok(request, {
accepted: true as const,
command: { kind: 'success' as const, ...result.text === undefined ? {} : { text: result.text } },
})
}
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
@@ -425,5 +478,93 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
respond(_message: ClientResponse): Promise<RpcReceipt> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
},
goals: {
async get(request) {
const { sessionId } = request.payload
const found = await agentFor(sessionId as SessionId)
if ('error' in found) return err(request, found.error)
const goal = ctx.goals.get(found.agent)
return ok(request, { goal: goal ? goalView(goal) : null })
},
async create(request) {
const { sessionId, objective, maxGoalRounds } = request.payload
const found = await agentFor(sessionId as SessionId)
if ('error' in found) return err(request, found.error)
try {
const goal = ctx.goals.create(found.agent, {
objective,
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
})
return ok(request, { goal: goalView(goal) })
} catch (error: unknown) {
return err(request, { code: 'internal', message: String(error), details: {} })
}
},
async edit(request) {
const { sessionId, ref, objective, maxGoalRounds } = request.payload
const found = await agentFor(sessionId as SessionId)
if ('error' in found) return err(request, found.error)
try {
const goal = ctx.goals.edit(found.agent, ref, {
...(objective !== undefined ? { objective } : {}),
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
})
return ok(request, { goal: goalView(goal) })
} catch (error: unknown) {
return err(request, { code: 'internal', message: String(error), details: {} })
}
},
async pause(request) {
const { sessionId, ref } = request.payload
const found = await agentFor(sessionId as SessionId)
if ('error' in found) return err(request, found.error)
try {
const goal = ctx.goals.pause(found.agent, ref)
return ok(request, { goal: goalView(goal) })
} catch (error: unknown) {
return err(request, { code: 'internal', message: String(error), details: {} })
}
},
async resume(request) {
const { sessionId, ref } = request.payload
const found = await agentFor(sessionId as SessionId)
if ('error' in found) return err(request, found.error)
try {
const goal = ctx.goals.resume(found.agent, ref)
return ok(request, { goal: goalView(goal) })
} catch (error: unknown) {
return err(request, { code: 'internal', message: String(error), details: {} })
}
},
async complete(request) {
const { sessionId, ref } = request.payload
const found = await agentFor(sessionId as SessionId)
if ('error' in found) return err(request, found.error)
try {
const goal = ctx.goals.complete(found.agent, ref)
return ok(request, { goal: goalView(goal) })
} catch (error: unknown) {
return err(request, { code: 'internal', message: String(error), details: {} })
}
},
async clear(request) {
const { sessionId, ref } = request.payload
const found = await agentFor(sessionId as SessionId)
if ('error' in found) return err(request, found.error)
try {
ctx.goals.clear(found.agent, ref)
return ok(request, { cleared: true as const })
} catch (error: unknown) {
return err(request, { code: 'internal', message: String(error), details: {} })
}
},
},
}
}

View File

@@ -37,6 +37,10 @@ import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import SpillLocal from '@deepseek-ai/dsh-spill-local'
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
import GoalService from '@deepseek-ai/dsh-goal'
import * as goalSession from '@deepseek-ai/dsh-goal-session'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
/** Options for bootHost — the assembly-layer composition knobs. */
export interface BootHostOptions {
@@ -129,5 +133,12 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
// Oversized tool output spills to session-scoped files (repl-agent budget).
await ctx.plugin(SpillLocal, {})
await ctx.plugin(spillPolicy, { maxInlineBytes: 50000 })
// Goal service and automatic same-session continuation.
await ctx.plugin(GoalService, {})
await ctx.plugin(goalSession)
// Human slash commands: the registry plus the /goal producer; the api-proxy
// prompt path dispatches leading-/ single-text-block prompts through them.
await ctx.plugin(CommandService)
await ctx.plugin(commandGoal)
return { ctx, defaults, dispose: () => ctx.fiber.dispose() }
}

View File

@@ -0,0 +1,197 @@
/**
* Host-side slash-command dispatch in sessions.prompt: a leading-/
* single-text-block prompt executes through the command registry and never
* reaches the model — symmetric with the ACP adapter. Successful commands
* return ok with the command slot; usage errors and unknown names return RPC
* errors so the client restores the composer's draft. Non-command prompts
* still route to agent.send/steer.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
interface Harness {
readonly ctx: Context
readonly agent: Agent
readonly session: Session
/** Content arguments of every agent.send/steer call, in order. */
readonly sent: ContentBlock[][]
readonly steered: ContentBlock[][]
}
/** Number the next balanced injection turn. */
function nextTurn(session: Session): number {
return session.events.reduce(
(maximum, event) => event.type === 'turn/start' ? Math.max(maximum, event.data.turn) : maximum,
0,
) + 1
}
/** Build a live idle agent whose send/steer calls are recorded. */
function stubAgent(id: string): { agent: Agent; session: Session; sent: ContentBlock[][]; steered: ContentBlock[][] } {
const session = new Session(SessionId(id))
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
options: {},
session,
ctx: new Context(),
get status() { return status },
send(content) { sent.push(content) },
steer(content) { steered.push(content) },
inject(content: ContentBlock[], options?: InjectOptions) {
const source: MessageSource = options?.source ?? { kind: 'user' }
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('context/message', {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
},
cancel() { status = 'idle' },
whenIdle() { return Promise.resolve() },
}
return { agent, session, sent, steered }
}
/** Mount the real command registry, goal domain, and /goal producer. */
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(CommandService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
await ctx.plugin(commandGoal)
const { agent, session, sent, steered } = stubAgent(`api-proxy-command-${Math.random()}`)
ctx.agents.register(agent)
return { ctx, agent, session, sent, steered }
}
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`command-${String(nextRpc++)}`), payload }
}
function promptPayload(test: Harness, text: string, mode: 'queue' | 'steer' = 'queue') {
const content: ContentBlock[] = [{ type: 'text', text }]
return request({ sessionId: test.session.id, mode, content })
}
describe('sessions.prompt slash-command dispatch', () => {
it('executes /goal <objective>: goal created, command slot carried, no model turn', async () => {
const test = await harness()
const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.prompt(promptPayload(test, '/goal fix the flaky test'))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.accepted).toBe(true)
expect(response.result.value.command?.kind).toBe('success')
expect(response.result.value.command?.text).toContain('Goal created')
const goal = test.ctx.goals.get(test.agent)
expect(goal?.objective).toBe('fix the flaky test')
// The prompt never reached the model: no send, no user/message event.
expect(test.sent).toEqual([])
expect(test.steered).toEqual([])
expect(test.session.events.filter(event => event.type === 'user/message')).toEqual([])
})
it('dispatches commands regardless of mode (steer prompt never steers)', async () => {
const test = await harness()
const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.prompt(promptPayload(test, '/goal', 'steer'))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.command?.text).toContain('No goal is currently set')
expect(test.sent).toEqual([])
expect(test.steered).toEqual([])
})
it('returns unknown-command for an unregistered name', async () => {
const test = await harness()
const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.prompt(promptPayload(test, '/bogus do something'))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('unknown-command')
expect(response.result.error.message).toBe('unknown command: /bogus')
expect(test.sent).toEqual([])
const bare = await api.sessions.prompt(promptPayload(test, '/bogus'))
expect(bare.result.ok).toBe(false)
if (!bare.result.ok) expect(bare.result.error.message).toBe('unknown command: /bogus')
})
it('carries a success without text when the command produced none', async () => {
const test = await harness()
const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
test.ctx.commands.register({ name: 'ping', description: 'test no-text success', handler: () => ({ kind: 'success' }) })
const response = await api.sessions.prompt(promptPayload(test, '/ping'))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.command).toEqual({ kind: 'success' })
expect(test.sent).toEqual([])
})
it('returns command-error for a usage error (bare /goal edit)', async () => {
const test = await harness()
const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.prompt(promptPayload(test, '/goal edit'))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('command-error')
expect(response.result.error.message).toContain('Goal editing requires a replacement objective')
expect(test.sent).toEqual([])
})
it('routes a non-command prompt to agent.send unchanged', async () => {
const test = await harness()
const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.prompt(promptPayload(test, 'hello there'))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.accepted).toBe(true)
expect('command' in response.result.value).toBe(false)
expect(test.sent).toEqual([[{ type: 'text', text: 'hello there' }]])
})
it('routes multi-block content starting with / to the model (never flattened)', async () => {
const test = await harness()
const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const content: ContentBlock[] = [{ type: 'text', text: '/goal not a command' }, { type: 'text', text: 'second' }]
const response = await api.sessions.prompt(request({ sessionId: test.session.id, mode: 'queue' as const, content }))
expect(response.result.ok).toBe(true)
expect(test.sent).toEqual([content])
})
it('routes degenerate content shapes to the model (empty array, single non-text block)', async () => {
const test = await harness()
const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const empty: ContentBlock[] = []
await api.sessions.prompt(request({ sessionId: test.session.id, mode: 'queue' as const, content: empty }))
const nonText: ContentBlock[] = [{ type: 'reasoning', text: '/goal not a command' }]
await api.sessions.prompt(request({ sessionId: test.session.id, mode: 'queue' as const, content: nonText }))
expect(test.sent).toEqual([empty, nonText])
})
})

View File

@@ -62,6 +62,15 @@
{
"path": "../../fs/tool-fs-search"
},
{
"path": "../../goal/goal"
},
{
"path": "../../goal/goal-session"
},
{
"path": "../../goal/command-goal"
},
{
"path": "../../llm/token-meter"
},
@@ -137,6 +146,9 @@
{
"path": "../../client/ui-conversation"
},
{
"path": "../../ui/commands"
},
{
"path": "../../client/ui-trajectory"
}