test: migrate consumers to inbox and owned-run APIs

This commit is contained in:
_Kerman
2026-07-30 17:28:03 +08:00
parent a6baddaaac
commit 5a0d26a0e4
72 changed files with 716 additions and 1381 deletions

View File

@@ -62,7 +62,7 @@ describe('permissions projection unit', () => {
expect(changes).toHaveLength(3)
expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } })
// Unrelated event: same-reference apply, no notification.
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
expect(changes).toHaveLength(3)
})

View File

@@ -1227,7 +1227,7 @@ export function createTuiChat(
if (cleanedUp) return
cleanedUp = true
detachSubmit()
detachDiscard()
detachSplice()
}
// Prepended so this wrapper is outermost: it observes the exact accepted
// message identity whether a downstream hook allows or blocks, then detaches.
@@ -1238,11 +1238,16 @@ export function createTuiChat(
if (decision.kind !== 'allow') return decision
return { ...decision, messages: [...decision.messages, attachedContext] }
}, { prepend: true })
// Installed before followup(): an enqueue listener can synchronously
// cancel and discard before followup() returns its id.
const detachDiscard = ctx.on('agent/inbox/discard', (subject, items) => {
if (subject !== agent) return
for (const item of items) discarded.add(item.message.id)
// Installed before followup(): an inbox observer can synchronously cancel
// the inserted message before followup() returns.
const detachSplice = ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'agent/inbox/spliced'
|| event.data.target !== 'next-turn' || event.data.outcome !== 'canceled') return
const removed = agent.inbox.nextTurn.slice(
event.data.start,
event.data.start + (event.data.removedCount ?? 0),
)
for (const item of removed) discarded.add(item.id)
if (discarded.has(acceptedId)) cleanup()
})
// followup() accepts any typed input and contains listener failures;
@@ -1463,6 +1468,15 @@ export function createTuiChat(
const disposeSessionEvents = ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') {
const removed = agent.inbox.nextStep.slice(
event.data.start,
event.data.start + (event.data.removedCount ?? 0),
)
let changed = false
for (const message of removed) changed = pendingSteering.delete(message.id) || changed
if (changed) refreshStatus()
}
if (event.type === 'tool/result') fileSearch.invalidate()
recordEventUsage(tokens, event)
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
@@ -1474,18 +1488,6 @@ export function createTuiChat(
renderEvent(event, { addHistory: false, renderChunks: true })
requestRender()
})
const settlePendingSteering = (id: MessageId): void => {
if (pendingSteering.delete(id)) refreshStatus()
}
const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, item) => {
if (subject === agent) settlePendingSteering(item.message.id)
})
const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, items) => {
if (subject !== agent) return
let changed = false
for (const item of items) changed = pendingSteering.delete(item.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
@@ -1526,8 +1528,6 @@ export function createTuiChat(
for (const value of promptValues) value.dispose()
stopBannerReveal()
disposeSessionEvents()
disposeDequeued()
disposeDiscarded()
disposeStatus()
disposeError()
disposeAgent()

View File

@@ -2,6 +2,7 @@ import { createUserMessage, MessageId , createMessage } from '@deepseek-ai/dsh-l
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, {
Inbox,
type Agent,
type AgentCancelCause,
type AgentOptions,
@@ -190,6 +191,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
id: sessionId,
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
session,
inbox: new Inbox(session),
status: options.status ?? 'idle',
ctx,
sent,

View File

@@ -387,7 +387,7 @@ describe('TUI terminal-state snapshots', () => {
})
harness.session.append('turn/end', {
turn: 1,
reason: { kind: 'aborted' },
reason: { kind: 'aborted', reason: { kind: 'user' } },
})
})
await checkpoint('retry-cancelled', harness.terminal, { includeScrollback: true })
@@ -407,8 +407,7 @@ describe('TUI terminal-state snapshots', () => {
turn: 1,
reason: {
kind: 'error',
step: 3,
failure: { message: 'provider still unavailable', code: 'SERVER', status: 503 },
error: { message: 'provider still unavailable', code: 'SERVER', status: 503 },
},
})
})
@@ -587,7 +586,7 @@ describe('TUI terminal-state snapshots', () => {
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 1,
reason: { kind: 'error', step: 1, message: `Unsafe turn error ${CONTROL_PROBE}` },
reason: { kind: 'error', error: `Unsafe turn error ${CONTROL_PROBE}` },
})
},
}, { columns: 100, rows: 34 })
@@ -771,7 +770,7 @@ describe('TUI terminal-state snapshots', () => {
harness.session.append('step/end', { turn: 1, step: 1 })
harness.session.append('turn/end', {
turn: 1,
reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' },
reason: { kind: 'error', error: 'provider stream failed after partial output' },
})
harness.session.append('turn/start', { turn: 2 })
harness.session.append('turn/end', {
@@ -779,7 +778,10 @@ describe('TUI terminal-state snapshots', () => {
reason: { kind: 'interrupted' },
})
harness.session.append('turn/start', { turn: 3 })
harness.session.append('turn/end', { turn: 3, reason: { kind: 'disposed' } })
harness.session.append('turn/end', {
turn: 3,
reason: { kind: 'aborted', reason: { kind: 'disposed' } },
})
harness.session.append('turn/start', { turn: 4 })
// A merge-extensible turn-end kind unknown to the TUI still surfaces its
// name so the agent never stops without a visible reason.

View File

@@ -5,17 +5,14 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, {
agentEvents, assembleContextFor, InboxItemId, type Agent, type InboxItem,
type InboxPlacement,
agentEvents, assembleContextFor, Inbox, type Agent,
} from '@deepseek-ai/dsh-agent'
import { createUserMessage,
createToolResultMessage,
ReasoningEffortId,
type LlmCallConfig,
type LlmModelReasoningInfo,
MessageId,
createMessage,
freezeMessage,
} from '@deepseek-ai/dsh-llm'
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
@@ -54,13 +51,6 @@ const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
render: () => [],
}
let nextInboxItem = 0
/** Wrap one test message in the production inbox occurrence envelope. */
function inboxItem(message: InboxItem['message'], placement: InboxPlacement): InboxItem {
return { id: InboxItemId(`tui-item-${nextInboxItem++}`), message, placement }
}
class FakeTerminal implements Terminal {
columns = 88
rows = 32
@@ -461,9 +451,9 @@ describe('goodbye message and /resume', () => {
})
it.each([
[{ kind: 'aborted' }, 'cancelled'],
[{ kind: 'error', step: 1, message: 'failed' }, 'error'],
[{ kind: 'disposed' }, 'disposed'],
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
[{ kind: 'error', error: new Error('failed') }, 'error'],
[{ kind: 'aborted', reason: { kind: 'disposed' } }, 'cancelled'],
[{ kind: 'max-tokens' }, 'max tokens'],
[{ kind: 'interrupted' }, 'interrupted'],
[{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
@@ -1342,7 +1332,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
}), { surfaceOp: 'append' })
appendAssistant(result.session, [])
result.session.append('step/end', { turn: 1, step: 1 })
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
result.session.append('turn/end', {
turn: 1,
reason: { kind: 'aborted', reason: { kind: 'user' } },
})
result.session.append('turn/start', { turn: 2 })
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
result.session.append('turn/start', { turn: 3 })
@@ -1660,16 +1653,16 @@ describe('pi-tui chat lifecycle and transcript', () => {
const submitSteering = (text: string): void => {
result.terminal.send(text)
result.terminal.send('\r')
const message = result.agent.steeredOptions.at(-1)
if (message !== undefined) {
result.agent.inbox.splice('next-step', result.agent.inbox.nextStep.length, 0, [message])
}
}
const drainSteering = (text: string): void => {
const id = result.agent.steeredIds.shift()
if (id !== undefined) {
result.ctx.emit('agent/inbox/dequeue', result.agent, inboxItem(freezeMessage({
id,
role: 'user',
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), 'steering'))
const index = result.agent.inbox.nextStep.findIndex(message => message.id === id)
if (index >= 0) result.agent.inbox.splice('next-step', index, 1, [], 'admitted')
}
result.session.append('steering/message', {
turn: 1,
@@ -1680,18 +1673,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
}, { surfaceOp: 'append' })
}
// A steering queue for a different agent never touches this status line.
const other = { ...result.agent, id: SessionId('other') } as Agent
result.terminal.output = ''
result.ctx.emit('agent/inbox/enqueue', other, inboxItem(freezeMessage({
id: MessageId('stub'),
role: 'user',
content: [{ type: 'text', text: 'elsewhere' }],
source: { kind: 'user' },
}), 'queued'))
await tick()
expect(result.terminal.output).not.toContain('queued')
// Two steering messages queue while the turn runs.
submitSteering('first')
result.terminal.output = ''
@@ -1753,34 +1734,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
submitSteering('fourth')
await tick()
expect(result.terminal.output).toContain('2 queued')
const discarded = result.agent.steeredIds.splice(0).map(id => freezeMessage({
id,
role: 'user' as const,
content: [{ type: 'text' as const, text: 'discarded' }],
source: { kind: 'user' as const },
}))
// Another agent's dequeue/discard, and ones naming no pending id, leave
// the badge alone.
result.ctx.emit('agent/inbox/dequeue', other, inboxItem(discarded[0]!, 'steering'))
result.ctx.emit('agent/inbox/dequeue', result.agent, inboxItem(freezeMessage({
id: MessageId('never-queued'),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
}), 'steering'))
result.ctx.emit('agent/inbox/discard', other, discarded.map(message => inboxItem(message, 'steering')))
result.ctx.emit('agent/inbox/discard', result.agent, [
inboxItem(freezeMessage({
id: MessageId('never-queued'),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
}), 'steering'),
])
await tick()
expect(result.terminal.output).toContain('2 queued')
result.terminal.output = ''
result.ctx.emit('agent/inbox/discard', result.agent, discarded.map(message => inboxItem(message, 'steering')))
result.agent.steeredIds.splice(0)
result.agent.inbox.splice(
'next-step',
0,
result.agent.inbox.nextStep.length,
[],
'canceled',
)
await tick()
expect(result.terminal.output).not.toContain('queued')
@@ -2096,12 +2058,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('tracks steering drains without a running status line', async () => {
const result = await setup()
const source = { kind: 'user' as const }
result.ctx.emit('agent/inbox/enqueue', result.agent, inboxItem(freezeMessage({
id: MessageId('stub'),
role: 'user',
content: [{ type: 'text', text: 'early' }],
source,
}), 'steering'))
result.session.append('steering/message', {
turn: 1,
message: createUserMessage({
@@ -2609,21 +2565,25 @@ describe('pi-tui chat lifecycle and transcript', () => {
// Idle: the snapshot rides the prompt's admission (additionalContexts on
// the allow decision), not a separate pre-admission inject.
expect(result.agent.injected).toHaveLength(0)
const submitted = result.agent.sentMessages[0]!
const decision = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [submitted],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [submitted] }),
)
expect(decision.kind).toBe('allow')
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
expect(decision.kind === 'allow'
&& decision.messages.find(message => message.source.kind === 'session-reference')?.source)
.toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'source-session' }] })
// The one-shot wrapper detached itself at admission: replaying the
// waterfall attaches nothing a second time.
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [submitted],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [submitted] }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
expect(replay.kind === 'allow' && replay.messages).toEqual([submitted])
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
result.agent.status = 'running'
@@ -2662,31 +2622,32 @@ describe('pi-tui chat lifecycle and transcript', () => {
// Running each prompt's admission waterfall detaches its wrapper.
for (const sent of result.agent.sentMessages) {
await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', sent,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [sent],
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const, messages: [sent] }),
)
}
// Both wrappers now gone: a discard naming either prompt's content finds
// no armed listener, and an unrelated admission is untouched. The leak
// regression: a listener installed after its cleanup already ran would
// survive every future cleanup.
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages[0]!, 'queued')])
const unrelatedMessage = createUserMessage({
content: [{ type: 'text', text: 'unrelated' }],
source: { kind: 'user' },
})
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'unrelated' }],
source: { kind: 'user' },
}),
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [unrelatedMessage],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [unrelatedMessage] }),
)
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
expect(unrelated.kind === 'allow' && unrelated.messages).toEqual([unrelatedMessage])
// Replaying either sent prompt attaches nothing: the one-shot wrappers
// are gone, not merely spent.
for (const sent of result.agent.sentMessages) {
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', sent,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [sent],
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const, messages: [sent] }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
expect(replay.kind === 'allow' && replay.messages).toEqual([sent])
}
await dispose(result)
})
@@ -2704,20 +2665,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
// Real send() publishes its already identified snapshot, then an enqueue
// listener may synchronously cancel and discard it before followup()
// returns that id. This stub reproduces that ordering.
const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
result.agent.followup = (input) => {
result.agent.sent.push(input.content)
result.agent.sentMessages.push(input)
const message = freezeMessage({
id: input.id,
role: 'user' as const,
content: structuredClone(input.content),
source: structuredClone(input.source),
})
result.ctx.emit('agent/inbox/enqueue', foreign, inboxItem(message, 'queued'))
result.ctx.emit('agent/inbox/enqueue', result.agent, inboxItem(message, 'queued'))
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(message, 'queued')])
return message.id
result.agent.inbox.splice('next-turn', 0, 0, [input])
result.agent.inbox.splice('next-turn', 0, 1, [], 'canceled')
}
result.terminal.send('@sync-source')
@@ -2731,10 +2683,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
// returned the existing id: replaying the prompt's admission attaches no
// stranded snapshot, and nothing leaks for the TUI lifetime.
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [result.agent.sentMessages[0]!],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [result.agent.sentMessages[0]!] }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
expect(replay.kind === 'allow' && replay.messages).toEqual([result.agent.sentMessages[0]!])
await dispose(result)
})
@@ -2762,22 +2715,25 @@ describe('pi-tui chat lifecycle and transcript', () => {
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
const blocked = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [result.agent.sentMessages[0]!],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [result.agent.sentMessages[0]!] }),
)
expect(blocked.kind).toBe('block')
// Nothing entered history and nothing waits for a later prompt: a fresh
// unrelated admission sees no leftover contexts.
expect(result.agent.injected).toHaveLength(0)
blockPrompts = false
const unrelatedMessage = createUserMessage({
content: [{ type: 'text', text: 'unrelated' }],
source: { kind: 'user' },
})
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'unrelated' }],
source: { kind: 'user' },
}),
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [unrelatedMessage],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [unrelatedMessage] }),
)
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
expect(unrelated.kind === 'allow' && unrelated.messages).toEqual([unrelatedMessage])
// Second referenced prompt, this time dropped by a broad cancel before
// any admission runs: the discard listener releases the wrapper.
@@ -2788,31 +2744,33 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
// A different prompt passing the still-armed wrapper delegates untouched.
const differentMessage = createUserMessage({
content: [{ type: 'text', text: 'different prompt' }],
source: { kind: 'user' },
})
const passthrough = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'different prompt' }],
source: { kind: 'user' },
}),
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [differentMessage],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [differentMessage] }),
)
expect(passthrough.kind === 'allow' && passthrough.additionalContexts).toBeUndefined()
// A foreign agent's discard leaves the wrapper armed.
const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
result.ctx.emit('agent/inbox/discard', foreign, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')])
// An unrelated discard for this agent also leaves the wrapper armed.
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(createUserMessage({
expect(passthrough.kind === 'allow' && passthrough.messages).toEqual([differentMessage])
// Canceling the exact pending message releases its wrapper.
const canceled = result.agent.sentMessages.at(-1)!
result.agent.inbox.splice('next-turn', 0, 0, [canceled])
result.agent.inbox.splice('next-turn', 0, 1, [], 'canceled')
const unrelatedDiscard = createUserMessage({
content: [{ type: 'text', text: 'unrelated discard' }],
source: { kind: 'user' },
}), 'queued')])
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')])
})
result.agent.inbox.splice('next-turn', 0, 0, [unrelatedDiscard])
result.agent.inbox.splice('next-turn', 0, 1, [], 'canceled')
await tick()
// Idempotent: a repeat discard after cleanup is a no-op.
result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')])
const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sentMessages.at(-1)!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [canceled],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [canceled] }),
)
expect(afterDiscard.kind === 'allow' && afterDiscard.additionalContexts).toBeUndefined()
expect(afterDiscard.kind === 'allow' && afterDiscard.messages).toEqual([canceled])
await dispose(result)
})
@@ -2963,11 +2921,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.agent.sent).toEqual([[
{ type: 'text', text: '@evil\\x1b\\x07\\x9b\\x0as' },
]])
const submitted = result.agent.sentMessages[0]!
const decision = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
'agent/prompt-submit', [submitted],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow' as const, messages: [submitted] }),
)
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
expect(decision.kind === 'allow'
&& decision.messages.find(message => message.source.kind === 'session-reference')?.source)
.toMatchObject({ references: [{ sessionId: unsafeId }] })
await dispose(result)
})
@@ -3738,11 +3699,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
events.session.append('step/end', { turn: 1, step: 1 })
events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } })
events.session.append('turn/end', { turn: 1, reason: { kind: 'error', error: 'live failure' } })
events.session.append('turn/start', { turn: 2 })
events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } })
events.session.append('turn/end', { turn: 2, reason: { kind: 'error', error: 'durable failure' } })
events.session.append('turn/start', { turn: 3 })
events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted' } })
events.session.append('turn/end', {
turn: 3,
reason: { kind: 'aborted', reason: { kind: 'user' } },
})
events.session.append('turn/start', { turn: 4 })
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
events.session.append('turn/start', { turn: 5 })
@@ -3750,10 +3714,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
events.session.append('turn/start', { turn: 6 })
events.session.append('turn/end', {
turn: 6,
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
reason: { kind: 'error', error: { message: 'structured provider failure', code: 'SERVER' } },
})
events.session.append('turn/start', { turn: 8 })
events.session.append('turn/end', { turn: 8, reason: { kind: 'disposed' } })
events.session.append('turn/end', {
turn: 8,
reason: { kind: 'aborted', reason: { kind: 'disposed' } },
})
events.session.append('turn/start', { turn: 9 })
// Merge-extensible reason kind unknown to the TUI still names the stop.
events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never })
@@ -4935,7 +4902,7 @@ describe('terminal mounting', () => {
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
@@ -4960,7 +4927,7 @@ describe('terminal mounting', () => {
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
@@ -4995,14 +4962,14 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({
id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx,
id: otherSession.id, options: {}, session: otherSession, inbox: new Inbox(otherSession), status: 'idle', ctx,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
expect(terminal.started).toBe(0)
const session = ctx.sessions.create(SessionId('late-session'))
const agent = {
id: session.id, options: {}, session, status: 'idle', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
@@ -5033,7 +5000,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'idle', ctx,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
@@ -5077,7 +5044,7 @@ describe('terminal mounting', () => {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, status: 'running', ctx,
id: session.id, options: {}, session, inbox: new Inbox(session), status: 'running', ctx,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()