diff --git a/.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.i18n.yaml new file mode 100644 index 0000000000..c25a2ffb2d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.md b/.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.md new file mode 100644 index 0000000000..11f6c696c7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.md @@ -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 ` 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. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.zh.md new file mode 100644 index 0000000000..b088351129 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-slash-command-dispatch.zh.md @@ -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 ` 创建目标、携带 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 对称,并由草稿恢复机制缓解。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e73fce4169..c49776d60d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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` diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index d993e763b7..86d041a93c 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -35,6 +35,8 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom()) }) }), 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 diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 46f737817c..d0c0892312 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -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': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 3edf0e6014..7e30e922e8 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -94,9 +94,13 @@ export const sessionPromptRequestSchema = z.object({ content: z.array(contentBlockSchema), }) as unknown as z.ZodType> -/** 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>> /** session.cancel request payload. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 393303f817..9919bc1f2c 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -64,9 +64,16 @@ export interface SessionsApi { history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): Promise> - /** 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> + Promise> /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 764c9673b9..681d4c6794 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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 }) diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index ea32040012..c36d87bc64 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -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:^", diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 0621fbecb9..744e2e6ad9 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -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(payload: F): RpcRequest { 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 { 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: {} }) + } + }, + }, } } diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index ca40b9f8b3..3686a10607 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -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 { // 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() } } diff --git a/packages/host/runtime/tests/api-proxy-command.spec.ts b/packages/host/runtime/tests/api-proxy-command.spec.ts new file mode 100644 index 0000000000..e2e5fbaf63 --- /dev/null +++ b/packages/host/runtime/tests/api-proxy-command.spec.ts @@ -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 { + 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

(payload: P): RpcRequest

{ + 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 : 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]) + }) +}) diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index 9836e0b5fd..cff10fef17 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -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" }