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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 }>>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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: {} })
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
197
packages/host/runtime/tests/api-proxy-command.spec.ts
Normal file
197
packages/host/runtime/tests/api-proxy-command.spec.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user