fix(apiproxy): derive queued steering on the client, drop it from the wire

The session/queued frame no longer carries steering — AgentMessage no longer
has the field. The client derives it from the same ordered turn boundaries the
host saw (a frame arriving while a turn is open joined the steering FIFO).
This commit is contained in:
_Kerman
2026-07-27 10:22:28 +08:00
parent 2f74ac98ba
commit 1aefcbf4d7
9 changed files with 46 additions and 32 deletions

View File

@@ -384,7 +384,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
let entries = queuedMirror.get(agent.id)
if (entries === undefined) queuedMirror.set(agent.id, entries = new Map<AgentMessageId, AgentMessage>())
entries.set(message.id, message)
broadcast({ type: 'session/queued', sessionId: agent.id, content: message.content, source: message.source, steering: message.steering })
broadcast({ type: 'session/queued', sessionId: agent.id, content: message.content, source: message.source })
}),
ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
retire(agent, message.id)
@@ -907,7 +907,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// queue view from these alone.
for (const [sessionId, entries] of queuedMirror) {
for (const entry of entries.values()) {
queue.push(frame({ type: 'session/queued', sessionId, content: entry.content, source: entry.source, steering: entry.steering }))
queue.push(frame({ type: 'session/queued', sessionId, content: entry.content, source: entry.source }))
}
}
// Per-session open-call table for result-view pairing. Bounded by the

View File

@@ -36,7 +36,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
// content/source reuse the wide passthroughs (both are merge-extensible in core).
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, content: z.array(contentBlockSchema), source: z.looseObject({ kind: z.string() }), steering: z.boolean() }),
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, content: z.array(contentBlockSchema), source: z.looseObject({ kind: z.string() }) }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<MuxFrame>

View File

@@ -72,7 +72,7 @@ export type MuxFrame =
* source carries the prompt's rpcId when the message came over this wire
* (the client's provisional-echo reconciliation key).
*/
| { type: 'session/queued'; sessionId: SessionId; content: ContentBlock[]; source: MessageSource; steering: boolean }
| { type: 'session/queued'; sessionId: SessionId; content: ContentBlock[]; source: MessageSource }
| { type: 'stream/error'; error: RpcError }
/**

View File

@@ -238,14 +238,11 @@ describe('host/commands-changed frame', () => {
})
/** Build one frozen inbox message for the live `agent/inbox/*` events. */
function inboxMessage(id: string, text: string, steering: boolean, rpcId?: string): AgentMessage {
function inboxMessage(id: string, text: string, rpcId?: string): AgentMessage {
return Object.freeze({
id: AgentMessageId(id),
content: [{ type: 'text' as const, text }],
source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
contexts: [],
steering,
wakeup: true,
})
}
@@ -259,15 +256,15 @@ describe('session/queued frames', () => {
// subscribed baseline + 2 queued frames
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
const queued = inboxMessage('m-1', 'queued prompt', false)
const steering = inboxMessage('m-2', 'queued prompt', true)
const queued = inboxMessage('m-1', 'queued prompt')
const steering = inboxMessage('m-2', 'queued prompt')
ctx.emit('agent/inbox/enqueue', agent, queued)
ctx.emit('agent/inbox/enqueue', agent, steering)
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
expect(liveFrames).toEqual([
{ type: 'session/queued', sessionId: agent.id, content: queued.content, source: { kind: 'user' }, steering: false },
{ type: 'session/queued', sessionId: agent.id, content: steering.content, source: { kind: 'user' }, steering: true },
{ type: 'session/queued', sessionId: agent.id, content: queued.content, source: { kind: 'user' } },
{ type: 'session/queued', sessionId: agent.id, content: steering.content, source: { kind: 'user' } },
])
// A fresh mux connection replays the still-pending entries as its baseline.
@@ -281,8 +278,8 @@ describe('session/queued frames', () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const queued = inboxMessage('m-3', 'x', false)
const steering = inboxMessage('m-4', 'x', true, 'r-1')
const queued = inboxMessage('m-3', 'x')
const steering = inboxMessage('m-4', 'x', 'r-1')
ctx.emit('agent/inbox/enqueue', agent, queued)
ctx.emit('agent/inbox/enqueue', agent, steering)
ctx.emit('agent/inbox/dequeue', agent, queued)
@@ -298,8 +295,8 @@ describe('session/queued frames', () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const doomed = inboxMessage('m-5', 'doomed', false)
const survivor = inboxMessage('m-6', 'survivor', false)
const doomed = inboxMessage('m-5', 'doomed')
const survivor = inboxMessage('m-6', 'survivor')
ctx.emit('agent/inbox/enqueue', agent, doomed)
ctx.emit('agent/inbox/enqueue', agent, survivor)
ctx.emit('agent/inbox/discard', agent, [doomed])

View File

@@ -240,8 +240,8 @@ describe('events frame schemas', () => {
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' }, steering: false },
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' }, steering: true },
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } },
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
@@ -261,9 +261,8 @@ describe('events frame schemas', () => {
})
it('rejects a queued frame missing its members', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [{ type: 'text' }], source: { kind: 'user' } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: 'x', source: { kind: 'user' }, steering: false })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: {}, steering: false })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: 'x', source: { kind: 'user' } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: {} })).toThrow()
})
it('accepts every host frame branch', () => {