Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

# Conflicts:
#	docs/event-producer-consumer.md
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-21 19:56:43 +08:00
509 changed files with 12825 additions and 2596 deletions

View File

@@ -8,7 +8,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view shows token-meter context occupancy, tool-card mode, and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.

View File

@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -25,6 +30,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -43,6 +49,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -737,7 +737,7 @@ class FooterComponent implements Component {
private readonly showReasoning: () => boolean,
private readonly tokens: () => { input: number; output: number },
private readonly currentModel: () => string | undefined,
private readonly contextPercent: () => number,
private readonly contextPercent: () => number | undefined,
private readonly runningSeconds: () => number,
) {}
@@ -755,7 +755,8 @@ class FooterComponent implements Component {
const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}`
const model = displayText(this.currentModel() ?? 'model unset')
const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})`
const context = `${this.contextPercent()}% context`
const contextPercent = this.contextPercent()
const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context`
const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}`
const compactRight = `${context} ${modelState}`
if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) {
@@ -1058,6 +1059,11 @@ export function createTuiChat(
let activeQuestion: PendingQuestion | undefined
let modelOverlay: OverlayHandle | undefined
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
let contextWindow: number | undefined
let contextResolution: Promise<
| { readonly kind: 'resolved'; readonly contextWindow: number | undefined }
| { readonly kind: 'error'; readonly error: unknown }
> | undefined
let modelCommands = Promise.resolve()
const now = (): number => runtime.now?.() ?? Date.now()
@@ -1070,7 +1076,9 @@ export function createTuiChat(
() => showReasoning,
() => tokens,
() => target.current?.model,
() => Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / ctx.tokenMeter.contextWindow * 100)),
() => contextWindow === undefined
? undefined
: Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)),
() => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)),
)
ui.addChild(header)
@@ -1096,12 +1104,34 @@ export function createTuiChat(
const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
contextWindow = undefined
const resolution = selected === undefined
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
: ctx.llm.resolveModelContext(selected.provider, selected.model).then(
context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const),
(error: unknown) => ({ kind: 'error', error } as const),
)
contextResolution = resolution
void resolution.then((result) => {
if (contextResolution !== resolution) return
if (result.kind === 'error') {
appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
return
}
contextWindow = result.contextWindow
requestRender()
})
}
resolveContextWindow(target.current)
const selectModel = (selected: ModelChoice): void => {
if (target.current?.provider === selected.provider && target.current.model === selected.model) {
appendNotice(`Model is already ${targetLabel(selected)}.`)
return
}
target.current = { provider: selected.provider, model: selected.model }
resolveContextWindow(target.current)
appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
}
@@ -1432,6 +1462,7 @@ export function createTuiChat(
const shutdown = (exitProcess: boolean): Promise<void> => {
shuttingDown ??= (async () => {
disposed = true
contextResolution = undefined
clearStatus()
modelOverlay?.hide()
modelOverlay = undefined

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tui`.
* @module @deepseek-ai/dsh-tui/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tui'
/** Cordis companion plugin name. */
export const name = 'tui-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
* boundary and replay tests cover its protocol mapping.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -1,7 +1,7 @@
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -31,6 +31,7 @@ export interface TuiHarnessOptions {
providers: LlmProviderInfo[]
models: LlmModelInfo[]
listModels?: (provider: string) => Promise<LlmModelInfo[]>
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
}
}
@@ -75,9 +76,12 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
return catalog.listModels?.(provider)
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
},
resolveModelContext(provider: string, model: string) {
return catalog.resolveModelContext?.(provider, model)
?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
},
} as never)
ctx.provide('tokenMeter', {
contextWindow: options.contextWindow ?? 128_000,
measure() {
return { totalTokens: options.contextTokens ?? 0 }
},
@@ -98,6 +102,11 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
sessionId,
options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
)
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('step/start', { turn: 1, step: 1 })
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
@@ -155,11 +164,10 @@ export function appendAssistant(
session: Session,
content: ContentBlock[],
usage?: { inputTokens: number; outputTokens: number },
position: { turn: number; step: number } = { turn: 1, step: 0 },
position: { turn: number; step: number } = { turn: 1, step: 1 },
): void {
session.append('assistant/message', {
turn: position.turn,
step: position.step,
...position,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content,
...usage === undefined ? {} : { usage },

View File

@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
@@ -100,7 +101,7 @@ async function disposeSnapshot(harness: SnapshotHarness): Promise<void> {
async function configureAdvancedTools(ctx: Context): Promise<void> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
ctx.provide('workflows', {} as never)
ctx.provide('workflows', { start() {} } as never)
await ctx.plugin(ToolWorkflow, { toolName: 'workflow', maxResultChars: 50_000 })
await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 })
}
@@ -121,7 +122,7 @@ function appendToolCalls(session: Session, calls: readonly ToolCallFixture[]): v
for (const call of calls) {
session.append('tool/call', {
turn: 1,
step: 0,
step: 1,
callId: CallId(call.id),
name: call.name,
arguments: JSON.stringify(call.arguments),
@@ -137,7 +138,7 @@ function appendToolResult(
): void {
session.append('tool/result', {
turn: 1,
step: 0,
step: 1,
callId: CallId(id),
content,
isError: options.isError ?? false,
@@ -208,23 +209,23 @@ describe('TUI terminal-state snapshots', () => {
harness.ctx.emit('agent/status', harness.agent, 'running')
appendUser(harness.session, 'Show the live update.')
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
})
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
})
})
@@ -431,9 +432,10 @@ describe('TUI terminal-state snapshots', () => {
source: { kind: 'user' },
reason: `Unsafe policy ${CONTROL_PROBE}`,
})
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 7,
reason: { kind: 'error', step: 2, message: `Unsafe turn error ${CONTROL_PROBE}` },
turn: 1,
reason: { kind: 'error', step: 1, message: `Unsafe turn error ${CONTROL_PROBE}` },
})
},
}, { columns: 100, rows: 34 })
@@ -455,7 +457,7 @@ describe('TUI terminal-state snapshots', () => {
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await harness.terminal.waitForFrame(beforeQuestion)
await renderAfter(harness, () => {
harness.ctx.emit('agent/error', harness.agent, 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
})
await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true })
@@ -517,14 +519,14 @@ describe('TUI terminal-state snapshots', () => {
}, { surfaceOp: 'append' })
const assistant = session.append('assistant/message', {
turn: 1,
step: 0,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 0, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
const result = session.append('tool/result', {
turn: 1,
step: 0,
step: 1,
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
isError: false,
@@ -560,13 +562,15 @@ describe('TUI terminal-state snapshots', () => {
harness.terminal.send('\r')
harness.terminal.send('/unknown-advanced-command')
harness.terminal.send('\r')
harness.ctx.emit('agent/error', harness.agent, 3, 1, new Error('provider stream failed after partial output'))
agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output'))
harness.session.append('step/end', { turn: 1, step: 1 })
harness.session.append('turn/end', {
turn: 3,
turn: 1,
reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' },
})
harness.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
harness.session.append('turn/end', {
turn: 4,
turn: 2,
reason: { kind: 'interrupted' },
})
})

View File

@@ -120,7 +120,6 @@ async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<
function provideTokenMeter(ctx: Context): void {
ctx.provide('tokenMeter', {
contextWindow: 128_000,
measure() {
return { totalTokens: 0 }
},
@@ -213,7 +212,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
result.agent.status = 'running'
result.ctx.emit('agent/status', result.agent, 'running')
agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
now = 8_000
result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
@@ -222,62 +221,65 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
appendAssistant(result.session, [])
result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } })
result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } })
result.session.append('step/start', { turn: 11, step: 0 })
result.session.append('step/end', { turn: 1, step: 1 })
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
result.session.append('step/start', { turn: 3, step: 1 })
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'live answer' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } },
})
await tick()
@@ -288,7 +290,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.session,
[{ type: 'text', text: 'final live answer' }],
{ inputTokens: 500, outputTokens: 8 },
{ turn: 2, step: 0 },
{ turn: 3, step: 1 },
)
await tick()
@@ -303,17 +305,17 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.session.append('assistant/chunk', {
turn: 3,
step: 0,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'cleared stream' },
})
result.terminal.send('/clear')
result.terminal.send('\r')
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }])
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 })
await tick()
expect(result.terminal.output).toContain('answer after clear')
result.agent.status = 'idle'
result.ctx.emit('agent/status', result.agent, 'idle')
agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
await tick()
expect(result.terminal.output).toContain('↑1.8k ↓50')
expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)')
@@ -433,8 +435,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
appendUser(session, 'first prompt')
appendUser(session, 'second prompt')
session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'stale partial response' },
})
},
@@ -542,8 +544,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
})
it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => {
const initialContext = Promise.withResolvers<{ contextWindow: number }>()
const result = await setup({
agentOptions: { provider: 'alpha', model: 'a1' },
contextTokens: 50,
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
models: [
@@ -552,6 +556,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
{ provider: 'beta', id: 'b1', name: 'Beta One' },
{ provider: 'beta', id: 'shared', name: 'Beta Shared' },
],
resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1'
? initialContext.promise
: Promise.resolve({ contextWindow: 200 }),
},
})
@@ -579,6 +586,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Model selected: beta/b1')
expect(result.agent.sent).toEqual([])
expect(result.agent.steered).toEqual([])
initialContext.resolve({ contextWindow: 100 })
await tick()
expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)')
result.terminal.send('/model')
result.terminal.send('\r')
@@ -589,7 +599,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.agent.status = 'idle'
result.ctx.emit('agent/status', result.agent, 'idle')
await tick()
expect(result.terminal.output).toContain('tools:compact b1(reasoning:on)')
expect(result.terminal.output).toContain('25% context tools:compact b1(reasoning:on)')
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
@@ -625,6 +635,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
resolveModelContext: () => Promise.resolve(undefined),
},
})
unset.terminal.send('/model')
@@ -633,6 +644,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
unset.terminal.send('\r')
await tick()
expect(unset.terminal.output).toContain('Model selected: alpha/a1')
expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)')
await dispose(unset)
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
@@ -654,12 +666,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
listModels: () => Promise.reject(new Error('catalog offline')),
resolveModelContext: () => Promise.reject(new Error('capacity offline')),
},
})
failed.terminal.send('/model')
failed.terminal.send('\r')
await tick()
expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline')
expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline')
await dispose(failed)
})
@@ -695,6 +709,21 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(rejectedResult.terminal.output).not.toContain('late catalog failure')
await rejectedResult.ctx.fiber.dispose()
const context = Promise.withResolvers<{ contextWindow: number }>()
const contextResult = await setup({
contextTokens: 99,
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
resolveModelContext: () => context.promise,
},
})
await contextResult.controller.dispose()
context.resolve({ contextWindow: 100 })
await tick()
expect(contextResult.terminal.output).not.toContain('99% context')
await contextResult.ctx.fiber.dispose()
})
it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
@@ -810,22 +839,30 @@ describe('pi-tui chat lifecycle and transcript', () => {
const events = await setup()
const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession }
unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
events.ctx.emit('agent/status', unrelatedAgent, 'running')
events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error'))
events.ctx.emit('agent/disposed', unrelatedAgent)
events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure'))
events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } })
events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } })
events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted', reason: 'stopped' } })
events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } })
events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } })
agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running')
agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error'))
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/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } })
events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'stopped' } })
events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 5, reason: { kind: 'rejected', reason: 'policy' } })
events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 6, reason: { kind: 'interrupted' } })
events.session.append('turn/start', { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', {
turn: 9,
turn: 7,
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
})
events.ctx.emit('agent/disposed', events.agent)
agentEvents(events.ctx, events.agent).emit('agent/disposed')
await tick()
expect(events.terminal.output).toContain('live failure')
expect(events.terminal.output).toContain('durable failure')
@@ -919,7 +956,7 @@ describe('tool cards and surface replay', () => {
})),
])
for (const [id, name, args] of calls) {
result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args })
result.session.append('tool/call', { turn: 1, step: 1, callId: id as never, name, arguments: args })
}
await tick()
expect(result.terminal.output).toContain('$ raw command')
@@ -929,23 +966,23 @@ describe('tool cards and surface replay', () => {
expect(result.terminal.output).toContain('call presenter boom')
expect(result.terminal.output).toContain('Symbol(input)')
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
turn: 1, step: 1, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
turn: 1, step: 1, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
turn: 1, step: 1, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
turn: 1, step: 1, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
meta: { value: 1 },
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c7' as never,
turn: 1, step: 1, callId: 'c7' as never,
content: [
{ type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
{ type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
@@ -954,13 +991,18 @@ describe('tool cards and surface replay', () => {
isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
turn: 1, step: 1, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
turn: 1, step: 1, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false,
turn: 1,
step: 1,
callId: 'orphan' as never,
content: [{ type: 'text', text: 'orphan result' }],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
}, { surfaceOp: 'append' })
await tick()
@@ -1001,15 +1043,15 @@ describe('tool cards and surface replay', () => {
appendUser(result.session, 'old prompt')
const assistant = result.session.append('assistant/message', {
turn: 1,
step: 0,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
result.session.append('tool/call', {
turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}',
turn: 1, step: 1, callId: 'old-call' as never, name: 'bash', arguments: '{}',
})
const toolResult = result.session.append('tool/result', {
turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
}, { surfaceOp: 'append' })
const start = result.session.surface.nodes[0] as number
result.session.append('context/message', {
@@ -1295,6 +1337,8 @@ describe('terminal mounting', () => {
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('failed-start-session'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, status: 'running', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
@@ -1312,7 +1356,7 @@ describe('terminal mounting', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
session.append('assistant/chunk', {
turn: 1,
step: 0,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'must not render' },
})
await tick()

View File

@@ -40,6 +40,9 @@
},
{
"path": "../user-interaction"
},
{
"path": "../../support/invariants"
}
]
}