fix(agent-loop): route next-step input during admission
This commit is contained in:
@@ -108,11 +108,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
private running = false
|
||||
/** Whether a turn is open on the live stream — set on turn/start, cleared on
|
||||
* turn/end. A session/queued frame arriving while this is true joined the
|
||||
* steering FIFO; the host no longer stamps steering on the frame, so the
|
||||
* client derives it from the same ordered turn boundaries the host saw. */
|
||||
private turnOpen = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
* synchronously before prompt()'s first await, never reset — the blank →
|
||||
@@ -342,12 +337,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
switch (frame.type) {
|
||||
case 'session/event': {
|
||||
this.retireQueued(frame.event)
|
||||
// Track turn-open state AFTER retirement (a message turn/start first
|
||||
// claims its queued entry, then opens the turn), so a later queued
|
||||
// frame is stamped steering iff a turn is open — the host no longer
|
||||
// stamps it on the frame.
|
||||
if (frame.event.type === 'turn/start') this.turnOpen = true
|
||||
else if (frame.event.type === 'turn/end') this.turnOpen = false
|
||||
this.acceptLiveEvent(frame.event, frame.view)
|
||||
return
|
||||
}
|
||||
@@ -357,7 +346,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
const key = 'rpcId' in frame.source ? String(frame.source.rpcId) : `f:${rpcId}`
|
||||
this.queued.push({
|
||||
row: { key, preview: queuePreviewOf(frame.content) },
|
||||
steering: this.turnOpen,
|
||||
steering: frame.steering,
|
||||
sourceJson: JSON.stringify(frame.source),
|
||||
})
|
||||
this.queueRev++
|
||||
|
||||
@@ -17,10 +17,11 @@ const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
|
||||
const rid = (id: string): RpcId => id as RpcId
|
||||
|
||||
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
|
||||
function queuedFrame(body: string, rpcId: string): MuxFrame {
|
||||
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
|
||||
return {
|
||||
type: 'session/queued', sessionId: SID, content: text(body),
|
||||
source: { kind: 'user', rpcId: rid(rpcId) } as never,
|
||||
steering,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ describe('queue intake', () => {
|
||||
type: 'session/queued', sessionId: SID,
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
source: { kind: 'plugin', plugin: 'loop' },
|
||||
steering: false,
|
||||
})
|
||||
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
|
||||
})
|
||||
@@ -86,14 +88,7 @@ describe('queue retirement (host queuedMirror rules)', () => {
|
||||
it('steering/message drains the source-matched steering row only', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering
|
||||
// Injection-triggered turn/start opens the turn without claiming a row, so
|
||||
// the next queued frame is derived steering (arrived mid-turn).
|
||||
const injection = {
|
||||
...ev.turnStart(0, 0),
|
||||
data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } },
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection })
|
||||
session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2')) // turn open → steering
|
||||
session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true))
|
||||
// Loop-authored steering (different source) must not consume the user entry.
|
||||
const foreignSteering = {
|
||||
seq: 0, time: 1,
|
||||
@@ -151,6 +146,19 @@ describe('queue reconnect semantics', () => {
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh'])
|
||||
})
|
||||
|
||||
it('replayed steering retires without a replayed turn/start', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true))
|
||||
const committed = {
|
||||
seq: 6, time: 2,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } },
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('manager buffering of queued frames', () => {
|
||||
|
||||
Reference in New Issue
Block a user