fix inbox lifecycle downstream contracts

This commit is contained in:
_Kerman
2026-07-31 22:00:39 +08:00
parent 8e88b17c9f
commit afedf18ccf
219 changed files with 5660 additions and 4556 deletions

View File

@@ -459,7 +459,7 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
const reason = event.data.reason
switch (reason.kind) {
case 'completed': return `turn ${event.data.turn}: completed`
case 'aborted': return `turn ${event.data.turn}: cancelled`
case 'aborted': return `turn ${event.data.turn}: ${reason.reason.kind === 'disposed' ? 'disposed' : 'cancelled'}`
case 'error': return `turn ${event.data.turn}: error`
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
case 'interrupted': return `turn ${event.data.turn}: interrupted`

View File

@@ -664,6 +664,16 @@ export function createTuiChat(
chat.addChild(streaming.timing)
}
const trailAssistantStep = (): void => {
if (streaming === undefined) return
for (const child of [streaming, streaming.timing]) {
const index = chat.children.indexOf(child)
/* v8 ignore next -- an open step keeps both assistant children attached until it settles or retracts. */
if (index >= 0) chat.children.splice(index, 1)
chat.addChild(child)
}
}
const renderEvent = (
event: SessionEvent,
options: {
@@ -682,6 +692,7 @@ export function createTuiChat(
if (references !== undefined) {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0))
trailAssistantStep()
break
}
const text = contentText(event.data.content).trim()
@@ -701,6 +712,7 @@ export function createTuiChat(
chat.addChild(new Spacer(1))
chat.addChild(card)
}
trailAssistantStep()
break
}
const text = displayText(contentText(event.data.content).trim())
@@ -709,6 +721,7 @@ export function createTuiChat(
chat.addChild(new UserMessageComponent(text, palette, mdTheme))
if (options.addHistory) editor.addToHistory(text)
}
trailAssistantStep()
break
}
case 'steering/message': {
@@ -799,7 +812,9 @@ export function createTuiChat(
break
}
case 'aborted':
appendNotice('Turn cancelled.', 'warning')
appendNotice(reason.reason.kind === 'disposed'
? 'Turn stopped: the agent was disposed.'
: 'Turn cancelled.', 'warning')
break
case 'max-tokens':
appendNotice('The model reached its output-token limit.', 'warning')

View File

@@ -11,26 +11,26 @@ viewport
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Reasoning "
style 0-8 dim italic
6| "Inspecting width and styles. "
style 0-27 dim italic
7| "Streaming visible state… "
style 10-22 bold
8| " "
9| "ts "
style 0-1 dim
10| " const visible = true "
style 2-21 fg=cyan
11| " "
12| "Model wait 1.0s · Thinking 2.0s "
style 0-30 dim
13| <blank>
14| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
15| "Show the live update. "
5| "Show the live update. "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Reasoning "
style 0-8 dim italic
9| "Inspecting width and styles. "
style 0-27 dim italic
10| "Streaming visible state… "
style 10-22 bold
11| " "
12| "ts "
style 0-1 dim
13| " const visible = true "
style 2-21 fg=cyan
14| " "
15| "Model wait 1.0s · Thinking 2.0s "
style 0-30 dim
16| <blank>
17| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-magenta bold

View File

@@ -11,15 +11,15 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Session inspected. "
6| "Model wait 0.0s "
style 0-14 dim
7| <blank>
8| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
9| "inspect this session "
5| "inspect this session "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Session inspected. "
9| "Model wait 0.0s "
style 0-14 dim
10| <blank>
11| "╭─ Session status ─────────────────────────────────────╮"
style 0-2 dim

View File

@@ -11,15 +11,15 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Session inspected. "
6| "Model wait 0.0s "
style 0-14 dim
7| <blank>
8| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
9| "inspect this session "
5| "inspect this session "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Session inspected. "
9| "Model wait 0.0s "
style 0-14 dim
10| <blank>
11| "╭─ Session status ────────────────────────────────────────────────────────────────────────╮"
style 0-2 dim

View File

@@ -11,13 +11,13 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises "
8| "wrapping and stays visible after compaction."
5| "Old prompt with a long line that exercises "
6| "wrapping and stays visible after compaction."
7| <blank>
8| "Assistant "
style 0-8 fg=bright-magenta bold underline
9| <blank>
10| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green

View File

@@ -11,12 +11,12 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
5| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| <blank>
9| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green

View File

@@ -11,13 +11,13 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises wrapping and stays visible after "
8| "compaction. "
5| "Old prompt with a long line that exercises wrapping and stays visible after "
6| "compaction. "
7| <blank>
8| "Assistant "
style 0-8 fg=bright-magenta bold underline
9| <blank>
10| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green

View File

@@ -11,12 +11,12 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
5| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| <blank>
9| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green

View File

@@ -11,15 +11,15 @@ viewport
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Tracking the steps. "
6| "Model wait 0.0s · Completed 2026-07-21 14:45:00 "
style 0-46 dim
7| <blank>
8| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
9| "Plan the work. "
5| "Plan the work. "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Tracking the steps. "
9| "Model wait 0.0s · Completed 2026-07-21 14:45:00 "
style 0-46 dim
10| <blank>
11| "You "
style 0-2 fg=bright-magenta bold underline

View File

@@ -11,30 +11,30 @@ buffer
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
4| "You "
style 0-2 fg=bright-magenta bold underline
7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
8| <blank>
9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 0-81 fg=green
10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-59 dim
11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-52 dim
12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-58 dim
13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
style 0-56 fg=red
14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 "
style 0-46 dim
15| <blank>
16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
5| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
6| <blank>
7| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 0-61 dim
17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
8| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-59 dim
9| <blank>
10| "Assistant "
style 0-8 fg=bright-magenta bold underline
11| <blank>
12| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 0-81 fg=green
13| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-59 dim
14| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-52 dim
15| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-58 dim
16| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
style 0-56 fg=red
17| "Model wait 0.0s · Completed 2026-07-21 15:00:00 "
style 0-46 dim
18| <blank>
19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-62 fg=red

View File

@@ -633,6 +633,10 @@ describe('TUI terminal-state snapshots', () => {
},
beforeMount(session) {
appendUser(session, `Unsafe user ${CONTROL_PROBE}`)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
}), { surfaceOp: 'append' })
appendAssistant(session, [
{ type: 'reasoning', text: `Unsafe reasoning ${CONTROL_PROBE}` },
{ type: 'text', text: `Unsafe assistant ${CONTROL_PROBE}` },
@@ -642,10 +646,6 @@ describe('TUI terminal-state snapshots', () => {
session.append('todo/write', {
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
}), { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 1,

View File

@@ -460,7 +460,7 @@ describe('goodbye message and /resume', () => {
it.each([
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
[{ kind: 'error', error: 'failed' }, 'error'],
[{ kind: 'aborted', reason: { kind: 'disposed' } }, 'cancelled'],
[{ kind: 'aborted', reason: { kind: 'disposed' } }, 'disposed'],
[{ kind: 'max-tokens' }, 'max tokens'],
[{ kind: 'interrupted' }, 'interrupted'],
[{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
@@ -1232,7 +1232,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
}
const result = await setup({
beforeMount(session) {
session.append('user/message', createUserMessage({
const message = createUserMessage({
content: renderGoalChange(change),
source: {
kind: 'goal',
@@ -1241,7 +1241,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
round: 0,
change,
},
}), { surfaceOp: 'append' })
})
session.append('agent/inbox/spliced', {
target: 'next-step', start: 0, inserted: [message],
})
},
})
expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
@@ -3772,6 +3775,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(events.terminal.output).toContain('live failure')
expect(events.terminal.output).toContain('durable failure')
expect(events.terminal.output).toContain('Turn cancelled')
expect(events.terminal.output).toContain('Turn stopped: the agent was disposed')
expect(events.terminal.output).toContain('structured provider failure')
expect(events.terminal.output).not.toContain('[object Object]')
expect(events.terminal.output).toContain('output-token limit')

View File

@@ -520,6 +520,25 @@ describe('approval policy (the approval/policy fold)', () => {
expect(narrations(session)).toHaveLength(1)
})
it('preserves a rejected pre-step without adding policy narration', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-narr-rejected')
appendHeader(session, ASK_MARKER)
setApprovalPolicy(session, 'never')
const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'reject' as const }),
)
expect(decision).toEqual({ kind: 'reject' })
expect(narrations(session)).toEqual([])
})
it('reads what the model was told back from the folded header text after a restart', async () => {
// A session whose last request carried the never sentence resumes under
// an ask default: the narrator attributes the change to the operator.