fix(web): bind prompts to plan selection
This commit is contained in:
@@ -18,7 +18,7 @@ Which plugins mount and with what defaults is decided only here — shells must
|
||||
|
||||
## ApiProxy implementation notes
|
||||
|
||||
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). `planMode` and `setPlanMode` use the same resume path, project the optional `ctx.planMode` service, canonicalize a net-zero cleanup intent by omitting `pending`, and return `null` when the service is not mounted. The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
|
||||
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). `planMode` and `setPlanMode` use the same resume path, project the optional `ctx.planMode` service, canonicalize a net-zero cleanup intent by omitting `pending`, and return `null` when the service is not mounted. A prompt carrying `planMode` sets that target and admits the message without an intervening await; missing capability fails closed, while synchronous admission failure restores the preceding target. The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -443,16 +443,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
async prompt(request) {
|
||||
const { sessionId, mode, content } = request.payload
|
||||
const { sessionId, mode, content, planMode: planTarget } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const agent = found.agent
|
||||
const planMode = ctx.get('planMode')
|
||||
if (planTarget !== undefined && planMode === undefined) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'prompt requested plan mode, but this host does not provide it',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
// No await separates selection from admission: another unary request
|
||||
// cannot interleave a different target between these two operations.
|
||||
const priorPlanState = planTarget === undefined ? undefined : planMode?.get(agent)
|
||||
const priorPlanTarget = priorPlanState?.pending ?? priorPlanState?.active
|
||||
if (planTarget !== undefined) planMode?.set(agent, planTarget)
|
||||
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
|
||||
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
|
||||
try {
|
||||
if (mode === 'steer') agent.steer(content, { source })
|
||||
else agent.send(content, { source })
|
||||
} catch (error: unknown) {
|
||||
if (priorPlanTarget !== undefined) planMode?.set(agent, priorPlanTarget)
|
||||
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
|
||||
}
|
||||
|
||||
@@ -273,6 +273,72 @@ describe('sessions.planMode / setPlanMode', () => {
|
||||
})
|
||||
|
||||
describe('sessions.prompt / cancel', () => {
|
||||
it('admits a prompt and its plan target through one host operation', async () => {
|
||||
const running = await boot([textResponse('planned')])
|
||||
await running.ctx.plugin(PlanModeService, { section: 'Plan before acting.' })
|
||||
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
|
||||
const agent = running.ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(running.ctx, agent)
|
||||
|
||||
expectOk(await running.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'plan this' }],
|
||||
planMode: true,
|
||||
})))
|
||||
await idle
|
||||
|
||||
const planEvent = agent.session.events.find(event => event.type === 'plan/mode')
|
||||
const userEvent = agent.session.events.find(event => event.type === 'user/message')
|
||||
const header = agent.session.events.find(event => event.type === 'request/header')
|
||||
expect(planEvent?.type === 'plan/mode' && planEvent.data.active).toBe(true)
|
||||
expect(planEvent?.seq).toBeLessThan(userEvent?.seq ?? Number.POSITIVE_INFINITY)
|
||||
expect(header?.type === 'request/header' && header.data.header.system).toContain('Plan before acting.')
|
||||
})
|
||||
|
||||
it('rolls back the plan target when prompt admission is rejected', async () => {
|
||||
const running = await boot()
|
||||
await running.ctx.plugin(PlanModeService, { section: 'Plan before acting.' })
|
||||
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
|
||||
const agent = running.ctx.agents.get(sessionId) as Agent
|
||||
const send = vi.spyOn(agent, 'send').mockImplementation(() => {
|
||||
throw new Error('closed for admission')
|
||||
})
|
||||
try {
|
||||
const response = await running.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'plan this' }],
|
||||
planMode: true,
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'agent-busy', details: { reason: 'Error: closed for admission' } },
|
||||
})
|
||||
expect(expectOk(await running.api.sessions.planMode(request({ sessionId })))).toEqual({
|
||||
active: false,
|
||||
})
|
||||
} finally {
|
||||
send.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed when a prompt targets unavailable plan mode', async () => {
|
||||
const running = await boot()
|
||||
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
|
||||
const response = await running.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'must not run ambiguously' }],
|
||||
planMode: true,
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'prompt requested plan mode, but this host does not provide it' },
|
||||
})
|
||||
expect((running.ctx.agents.get(sessionId) as Agent).session.events).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'host default', config: true, target: '5 words', maxTokens: 64 },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user