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

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