refactor(agent): minimize inbox message contract

This commit is contained in:
_Kerman
2026-07-24 17:00:42 +08:00
parent b3c1abac67
commit 90e69a3123
16 changed files with 58 additions and 124 deletions

View File

@@ -1592,15 +1592,10 @@ export function createTuiChat(
let toolsExpanded = false
let streaming: StreamingAssistantComponent | undefined
let runningStatus: RunningStatus | undefined
// Steering messages queued during the running turn (`agent/inbox/enqueue`
// with `info.steering`) that the loop has not yet drained, shown as a badge on
// the status line. Each entry is the queued message's serialized source: a
// drain (`steering/message`) removes one MATCHING entry, so a loop-authored
// continuation reason (which enqueues and drains under its own source) pushes
// and pops its own slot and cannot consume a pending user message's slot.
// Cleared on leaving `running`, which also absorbs a cancellation that
// discards the queue without logging drains; the status line exists only
// while running, so idle carries no badge to keep current.
// TUI steering submissions that the loop has not yet drained, shown as a
// badge on the status line. Each entry is the submitted message's serialized
// source, so an unrelated steering/message cannot consume its slot. Leaving
// `running` clears entries discarded by cancellation.
const pendingSteering: string[] = []
let disposed = false
let shuttingDown: Promise<void> | undefined
@@ -2505,7 +2500,10 @@ export function createTuiChat(
if (disposed) {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
} else if (agent.status === 'running') {
agent.steer(content, { source: { kind: 'user' } })
const source = { kind: 'user' } as const
agent.steer(content, { source })
pendingSteering.push(JSON.stringify(source))
refreshStatus()
} else {
agent.followup(content, { source: { kind: 'user' } })
}
@@ -2751,11 +2749,6 @@ export function createTuiChat(
renderEvent(event, { addHistory: false, renderChunks: true })
requestRender()
})
const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => {
if (subject !== agent || !info.steering) return
pendingSteering.push(JSON.stringify(info.source))
refreshStatus()
})
const disposeStatus = ctx.on('agent/status', (subject, status) => {
if (subject !== agent) return
// Leaving 'running' ends the turn's status line; clear any badge so the
@@ -2784,7 +2777,6 @@ export function createTuiChat(
disposeCommandChanges()
stopBannerReveal()
disposeSessionEvents()
disposeQueued()
disposeStatus()
disposeError()
disposeAgent()

View File

@@ -574,8 +574,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
expect(result.terminal.output).not.toContain('queued')
const queueSteering = (text: string): void => {
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, steering: true, wakeup: true })
const submitSteering = (text: string): void => {
result.terminal.send(text)
result.terminal.send('\r')
}
const drainSteering = (text: string): void => {
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
@@ -584,20 +585,19 @@ describe('pi-tui chat lifecycle and transcript', () => {
// A steering queue for a different agent never touches this status line.
const other = { ...result.agent, id: SessionId('other') } as unknown as Agent
result.terminal.output = ''
result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, steering: true, wakeup: true })
result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' } })
await tick()
expect(result.terminal.output).not.toContain('queued')
// Two steering messages queue while the turn runs.
queueSteering('first')
submitSteering('first')
result.terminal.output = ''
queueSteering('second')
submitSteering('second')
await tick()
expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels')
// A non-steering queue (an idle-style send) leaves the badge untouched.
// Draining one submitted message decrements the badge.
result.terminal.output = ''
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, steering: false, wakeup: true })
drainSteering('first')
await tick()
expect(result.terminal.output).toContain('1 queued')
@@ -613,7 +613,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// A drain with no matching queued entry is ignored rather than underflowing.
result.terminal.output = ''
drainSteering('continuation')
queueSteering('after')
submitSteering('after')
await tick()
expect(result.terminal.output).toContain('1 queued')
@@ -649,9 +649,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('derives the fine-grained turn phase from session lifecycle events', async () => {
// A live event before the turn runs has no status controller to move.
const idle = await setup()
// A steering queue arriving while idle has no status line to badge, so the
// refresh is a no-op beyond requesting a render.
idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, steering: true, wakeup: true })
// Inbox notifications do not affect the status phase while idle.
idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' } })
idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' })
await tick()
expect(idle.terminal.output).not.toContain('Executing tools')