refactor(agent): trim obsolete loop surfaces

This commit is contained in:
_Kerman
2026-07-24 21:58:07 +08:00
parent 194b18a32f
commit 009d113e0e
40 changed files with 252 additions and 288 deletions

View File

@@ -17,8 +17,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
*
* `completed` and the defensive `error` case map to `end_turn`;
* `max-tokens` maps to `max_tokens`; `aborted`, `disposed`, and `rejected` map
* to `cancelled`. The bridge rejects error turns before this mapping. Unknown
* `max-tokens` maps to `max_tokens`; `aborted` and `disposed` map to
* `cancelled`. The bridge rejects error turns before this mapping. Unknown
* merge-extensible kinds use legal fallback `end_turn` rather than breaking
* the prompt RPC.
* @param reason - the harness turn-end reason to translate.
@@ -34,8 +34,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
return 'cancelled'
case 'disposed':
return 'cancelled'
case 'rejected':
return 'cancelled'
case 'error':
return 'end_turn'
// Merge-extensible: an unknown future TurnEndReason kind still has to produce a legal wire

View File

@@ -20,7 +20,6 @@ describe('turnEndToStopReason', () => {
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
expect(turnEndToStopReason({ kind: 'aborted' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn')
})

View File

@@ -41,6 +41,7 @@ import z from 'schemastery'
import {
installAgentLlmTarget,
type Agent,
type AgentMessageId,
type AgentLlmTarget,
type AgentLlmTargetRef,
type AgentStatus,
@@ -1299,7 +1300,6 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
case 'error': return `turn ${event.data.turn}: error`
case 'disposed': return `turn ${event.data.turn}: disposed`
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
case 'rejected': return `turn ${event.data.turn}: rejected`
case 'interrupted': return `turn ${event.data.turn}: interrupted`
default: return `turn ${event.data.turn}: unknown result`
}
@@ -1875,11 +1875,10 @@ export function createTuiChat(
let toolsExpanded = false
let streaming: StreamingAssistantComponent | undefined
let runningStatus: RunningStatus | undefined
// 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[] = []
// TUI steering submissions that the inbox has not yet claimed or discarded.
// Correlation ids avoid guessing whether a running-state submission actually
// joined steering or fell back to the queued-turn FIFO during turn close.
const pendingSteering = new Set<AgentMessageId>()
let disposed = false
let shuttingDown: Promise<void> | undefined
// Optional: skills mount conditionally, so read the global service store
@@ -2124,7 +2123,7 @@ export function createTuiChat(
const renderStatus = (running: RunningStatus): void => {
const at = now()
running.loader.setMessage(
formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.length),
formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.size),
)
}
@@ -2152,7 +2151,7 @@ export function createTuiChat(
const phase = prior?.phase ?? 'waiting'
const phaseStartedAt = prior?.phaseStartedAt ?? at
const stepStartedAt = prior?.stepStartedAt ?? at
const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.length)
const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.size)
const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message)
statusContainer.addChild(loader)
const running: RunningStatus = {
@@ -2264,9 +2263,6 @@ export function createTuiChat(
}
break
}
case 'prompt/blocked':
appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning')
break
case 'assistant/chunk':
if (options.renderChunks) {
if (streaming === undefined) {
@@ -2326,8 +2322,6 @@ export function createTuiChat(
appendNotice('Turn cancelled.', 'warning')
} else if (event.data.reason.kind === 'max-tokens') {
appendNotice('The model reached its output-token limit.', 'warning')
} else if (event.data.reason.kind === 'rejected') {
appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning')
} else if (event.data.reason.kind === 'interrupted') {
appendNotice('The previous process ended during this turn.', 'warning')
}
@@ -2788,8 +2782,7 @@ export function createTuiChat(
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
} else if (agent.status === 'running') {
const source = { kind: 'user' } as const
agent.steer(content, { source })
pendingSteering.push(JSON.stringify(source))
pendingSteering.add(agent.steer(content, { source }))
refreshStatus()
} else {
agent.followup(content, { source: { kind: 'user' } })
@@ -3137,17 +3130,6 @@ export function createTuiChat(
if (event.type === 'tool/result') fileSearch.invalidate()
recordEventUsage(tokens, event)
advanceTurnPhase(event)
if (event.type === 'steering/message') {
// A queued steering message reached the model as it drained; drop its
// entry from the badge. Matching by source keeps a loop-authored
// continuation reason popping its own enqueued slot rather than a pending
// user message's slot.
const drained = pendingSteering.indexOf(JSON.stringify(event.data.source))
if (drained >= 0) {
pendingSteering.splice(drained, 1)
refreshStatus()
}
}
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
rebuildTranscript(false)
return
@@ -3155,12 +3137,24 @@ export function createTuiChat(
renderEvent(event, { addHistory: false, renderChunks: true })
requestRender()
})
const settlePendingSteering = (id: AgentMessageId): void => {
if (pendingSteering.delete(id)) refreshStatus()
}
const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => {
if (subject === agent) settlePendingSteering(message.id)
})
const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, messages) => {
if (subject !== agent) return
let changed = false
for (const message of messages) changed = pendingSteering.delete(message.id) || changed
if (changed) 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
// next running turn starts from zero (and a cancellation, which discards
// the queue without logging drains, cannot strand a stale count).
if (status !== 'running') pendingSteering.length = 0
if (status !== 'running') pendingSteering.clear()
setStatus(status)
})
const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
@@ -3183,6 +3177,8 @@ export function createTuiChat(
disposeCommandChanges()
stopBannerReveal()
disposeSessionEvents()
disposeDequeued()
disposeDiscarded()
disposeStatus()
disposeError()
disposeAgent()

View File

@@ -23,6 +23,7 @@ interface FakeAgent extends Agent {
sent: ContentBlock[][]
sentOptions: (SendOptions | AliasSendOptions | undefined)[]
steered: ContentBlock[][]
steeredIds: AgentMessageId[]
steeredOptions: (AliasSendOptions | undefined)[]
injected: ContentBlock[][]
injectedOptions: (AliasSendOptions | undefined)[]
@@ -161,6 +162,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
const steeredIds: AgentMessageId[] = []
const sentOptions: (SendOptions | AliasSendOptions | undefined)[] = []
const steeredOptions: (AliasSendOptions | undefined)[] = []
const injected: ContentBlock[][] = []
@@ -175,6 +177,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
sent,
sentOptions,
steered,
steeredIds,
steeredOptions,
injected,
injectedOptions,
@@ -192,7 +195,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
steer(content, options) {
steered.push(content)
steeredOptions.push(options)
return AgentMessageId('stub')
const id = AgentMessageId(`steering-${steeredIds.length + 1}`)
steeredIds.push(id)
return id
},
inject(content, options) {
injected.push(content)

View File

@@ -1363,6 +1363,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r')
}
const drainSteering = (text: string): void => {
const id = result.agent.steeredIds.shift()
if (id !== undefined) {
result.ctx.emit('agent/inbox/dequeue', result.agent, {
id,
content: [{ type: 'text', text }],
source: { kind: 'user' },
})
}
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
}
@@ -1401,9 +1409,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(result.terminal.output).toContain('1 queued')
// A steering/message whose source matches no pending badge entry (here a
// plugin source with no tracked enqueue) pops nothing, so it cannot consume
// a pending user slot even when it drains first.
// A steering/message has no inbox identity and therefore cannot consume a
// pending slot by itself.
result.terminal.output = ''
result.session.append('steering/message', {
turn: 1,