Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/tests/llm-target.spec.ts
#	packages/core/session/tests/request-header.spec.ts
This commit is contained in:
_Kerman
2026-07-27 16:44:06 +08:00
220 changed files with 4592 additions and 969 deletions

View File

@@ -8,7 +8,12 @@ import AgentRegistry, {
type AgentStatus,
type SendOptions,
} from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
LlmModelInfo,
LlmProviderInfo,
LlmResolvedModelInfo,
} from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessageData } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -50,7 +55,10 @@ export interface TuiHarnessOptions {
providers: LlmProviderInfo[]
models: LlmModelInfo[]
listModels?: (provider: string) => Promise<LlmModelInfo[]>
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
resolveModelInfo?: (
provider: string,
model: string,
) => Promise<Pick<LlmResolvedModelInfo, 'context' | 'reasoning'>>
}
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
sessionPersistence?: {
@@ -121,9 +129,20 @@ 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 })
async resolveModelInfo(provider: string, model: string) {
const advertised = catalog.models.find(candidate =>
candidate.provider === provider && candidate.id === model)
const capabilities = await (catalog.resolveModelInfo?.(provider, model)
?? Promise.resolve({
context: { contextWindow: options.contextWindow ?? 128_000 },
}))
return {
provider,
id: model,
name: advertised?.name ?? model,
...advertised?.description === undefined ? {} : { description: advertised.description },
...capabilities,
}
},
} as never)
}

View File

@@ -0,0 +1,42 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=92 viewportRow=15 bufferRow=15
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
7-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
style 8-83 fg=bright-blue
14| " │ deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ "
style 8-8 fg=bright-blue
style 38-77 fg=bright-black
style 83-83 fg=bright-blue
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
style 8-8 fg=bright-blue
style 10-77 fg=bright-blue inverse
style 83-83 fg=bright-blue
16| " │ │ "
style 8-8 fg=bright-blue
style 83-83 fg=bright-blue
17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel │ "
style 8-8 fg=bright-blue
style 10-71 dim
style 83-83 fg=bright-blue
18| " ╰──────────────────────────────────────────────────────────────────────────╯ "
style 8-83 fg=bright-blue
19-31| <blank>

View File

@@ -20,23 +20,23 @@ buffer
style 0-43 dim
style 65-91 dim
7-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────╮ "
style 10-81 fg=bright-blue
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current "
style 10-10 fg=bright-blue
style 12-72 fg=bright-blue inverse
style 81-81 fg=bright-blue
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro "
style 10-10 fg=bright-blue
style 38-60 fg=bright-black
style 81-81 fg=bright-blue
16| " "
style 10-10 fg=bright-blue
style 81-81 fg=bright-blue
17| " │ ↑/↓ navigate • Enter select • Esc cancel "
style 10-10 fg=bright-blue
style 12-51 dim
style 81-81 fg=bright-blue
18| " ╰──────────────────────────────────────────────────────────────────────╯ "
style 10-81 fg=bright-blue
13| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
style 8-83 fg=bright-blue
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current "
style 8-8 fg=bright-blue
style 10-77 fg=bright-blue inverse
style 83-83 fg=bright-blue
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
style 8-8 fg=bright-blue
style 36-77 fg=bright-black
style 83-83 fg=bright-blue
16| " │ "
style 8-8 fg=bright-blue
style 83-83 fg=bright-blue
17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel "
style 8-8 fg=bright-blue
style 10-71 dim
style 83-83 fg=bright-blue
18| " ╰──────────────────────────────────────────────────────────────────────────╯ "
style 8-83 fg=bright-blue
19-31| <blank>

View File

@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=6 bufferRow=6
cursor hidden column=1 viewportRow=7 bufferRow=7
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
@@ -11,15 +11,17 @@ buffer
2| " deepseek-v4-pro • main-session"
style 1-32 dim
3| <blank>
4| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
style 1-64 fg=bright-black
5| "────────────────────────────────────────────────────────────────────────────────────────────"
4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: provider default. New steps "
style 1-91 fg=bright-black
5| " will use it. "
style 1-12 fg=bright-black
6| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| " "
7| " "
style 1-1 inverse
7| "────────────────────────────────────────────────────────────────────────────────────────────"
8| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
8| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed"
9| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-41 dim
style 65-91 dim
9-31| <blank>
10-31| <blank>

View File

@@ -41,13 +41,13 @@ buffer
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
16| "│ Model: deepseek/deepseek-v4-pro (reasoning │"
16| "│ Model: deepseek/deepseek-v4-pro (effort │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-55 dim
17| "│ shown) │"
17| "│ default; reasoning blocks shown) │"
style 0-0 dim
style 15-20 dim
style 15-46 dim
style 55-55 dim
18| "│ │"
style 0-0 dim

View File

@@ -25,68 +25,68 @@ buffer
style 1-9 fg=bright-magenta bold
10| " Session inspected. "
11| <blank>
12| "╭─ Session status ─────────────────────────────────────────────────╮"
12| "╭─ Session status ───────────────────────────────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-blue bold
style 17-67 dim
13| "│ Session: main-session │"
style 17-81 dim
13| "│ Session: main-session │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
14| "│ Title: Inspect session diagnostics │"
style 81-81 dim
14| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
15| "│ Directory: /workspace/project │"
style 81-81 dim
15| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
16| "│ Model: deepseek/deepseek-v4-pro (reasoning shown) │"
style 81-81 dim
16| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-56 dim
style 67-67 dim
17| "│ │"
style 40-79 dim
style 81-81 dim
17| "│ │"
style 0-0 dim
style 67-67 dim
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
style 81-81 dim
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
19| "│ │"
style 81-81 dim
19| "│ │"
style 0-0 dim
style 67-67 dim
20| "│ Tokens: 1,250 input + 340 output │"
style 81-81 dim
20| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 81-81 dim
21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-26 fg=bright-blue
style 27-32 dim
style 67-67 dim
22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 81-81 dim
22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-20 fg=bright-blue
style 21-32 dim
style 67-67 dim
23| "│ │"
style 81-81 dim
23| "│ │"
style 0-0 dim
style 67-67 dim
24| "│ Created: 2026-07-22 09:10:11 UTC │"
style 81-81 dim
24| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
25| "│ Active: 2026-07-22 09:10:11 UTC │"
style 81-81 dim
25| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
26| "╰──────────────────────────────────────────────────────────────────╯"
style 0-67 dim
style 81-81 dim
26| "╰────────────────────────────────────────────────────────────────────────────────╯"
style 0-81 dim
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
28| " "

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId, ReasoningEffortId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -45,6 +45,7 @@ const CHECKPOINTS = [
'surface-after-compaction-narrow',
'surface-after-compaction-wide',
'model-selector',
'model-effort-switching',
'model-switching',
'errors-and-help',
'disposed-terminal',
@@ -627,8 +628,29 @@ describe('TUI terminal-state snapshots', () => {
await harness.terminal.dispose()
})
it('pins the model selector and selection notice', async () => {
const harness = await setupSnapshot({}, { columns: 92, rows: 32 })
it('pins the model selector, effort cycling, and provider-default selection', async () => {
const harness = await setupSnapshot({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
resolveModelInfo: (_provider, model) => Promise.resolve({
context: { contextWindow: 128_000 },
reasoning: {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
...model === 'deepseek-v4-flash'
? { defaultEffort: ReasoningEffortId('high') }
: {},
},
}),
},
}, { columns: 92, rows: 32 })
await renderAfter(harness, () => {
harness.terminal.send('/model')
harness.terminal.send('\r')
@@ -636,6 +658,13 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('model-selector', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.terminal.send('\x1b[B')
harness.terminal.send('\x1b[Z')
harness.terminal.send('\x1b[Z')
harness.terminal.send('\x1b[Z')
harness.terminal.send('\x1b[Z')
})
await checkpoint('model-effort-switching', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.terminal.send('\r')
})
await checkpoint('model-switching', harness.terminal, { includeScrollback: true })

View File

@@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import { type LlmCallConfig } from '@deepseek-ai/dsh-llm'
import {
ReasoningEffortId,
type LlmCallConfig,
type LlmModelReasoningInfo,
} 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'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -143,7 +147,11 @@ function provideLlmCatalog(ctx: Context): void {
ctx.provide('llm', {
listProviders: () => [],
listModels: () => Promise.resolve([]),
resolveModelContext: () => Promise.resolve(undefined),
resolveModelInfo: (provider: string, model: string) => Promise.resolve({
provider,
id: model,
name: model,
}),
} as never)
}
@@ -157,7 +165,7 @@ describe('TUI config', () => {
maxResumeOptions: 8,
questionDialogWidth: 200,
questionDialogMaxHeight: 20,
modelDialogWidth: 72,
modelDialogWidth: 76,
modelDialogMaxHeight: 20,
fileSearchMaxResults: 20,
fileSearchMaxEntries: 10_000,
@@ -1161,7 +1169,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('restored answer')
expect(result.terminal.output).toContain('write tests')
expect(result.terminal.output).toContain('↑1.3k ↓42')
// Context resolution is async (resolveModelContext); settle before reading.
// Exact model resolution is async; settle before reading.
await tick()
expect(result.terminal.output).toContain('42% context tools:collapsed')
// Narrow terminals clip the right-hand context/tools segment first; the
@@ -1748,7 +1756,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('main-session')
expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07')
expect(result.terminal.output).toContain('/workspace/status')
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (reasoning hidden)')
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks')
expect(result.terminal.output).toContain('hidden)')
expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls')
expect(result.terminal.output).toContain('1,250 input + 340 output')
expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)')
@@ -1776,7 +1785,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
catalog: {
providers: [],
models: [],
resolveModelContext: () => Promise.resolve(undefined),
resolveModelInfo: () => Promise.resolve({}),
},
})
result.terminal.send('/status')
@@ -1784,7 +1793,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(result.terminal.output).toContain('untitled')
expect(result.terminal.output).toContain('unset (reasoning shown)')
expect(result.terminal.output).toContain('unset (effort unset; reasoning blocks shown)')
expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls')
expect(result.terminal.output).toContain('n/a (0 read + 0 write)')
expect(result.terminal.output).toContain('7 used · capacity unknown')
@@ -2498,6 +2507,7 @@ 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 }>()
let deferInitialContext = true
const result = await setup({
agentOptions: { provider: 'alpha', model: 'a1' },
contextTokens: 50,
@@ -2509,9 +2519,42 @@ 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 }),
async resolveModelInfo(provider, model) {
const shouldDeferContext = provider === 'alpha' && model === 'a1' && deferInitialContext
if (shouldDeferContext) deferInitialContext = false
const context = shouldDeferContext
? await initialContext.promise
: { contextWindow: 200 }
let reasoning: LlmModelReasoningInfo | undefined
if (model === 'a1') {
reasoning = {
efforts: [
{ id: ReasoningEffortId('low'), name: 'Low' },
{ id: ReasoningEffortId('high'), name: 'High' },
],
defaultEffort: ReasoningEffortId('low'),
}
} else if (model === 'b1') {
reasoning = {
efforts: [
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId('high'),
}
} else if (provider === 'alpha' && model === 'shared') {
reasoning = {
efforts: [
{ id: ReasoningEffortId('standard'), name: 'Standard' },
{ id: ReasoningEffortId('ultra'), name: 'Ultra' },
],
}
}
return {
context,
...reasoning === undefined ? {} : { reasoning },
}
},
},
})
@@ -2536,6 +2579,92 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\x1b')
await tick()
const providerDefaultOutput = result.terminal.output.length
result.terminal.send('/model alpha/shared')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: provider default.')
})
result.terminal.send('/model')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Select model')
})
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — provider default')
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — Standard')
result.terminal.send('\r')
await tick()
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: Standard.')
const resetDefaultOutput = result.terminal.output.length
result.terminal.send('/model')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Standard — current')
})
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Ultra — current')
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — provider default')
result.terminal.send('\r')
await tick()
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Reasoning effort: provider default.')
const explicitResetSeed: LlmCallConfig = {
provider: 'beta',
model: 'b1',
reasoningEffort: ReasoningEffortId('max'),
}
await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
await expect(agentEvents(result.ctx, result.agent).waterfall(
'agent/request',
0,
0,
explicitResetSeed,
new AbortController().signal,
() => Promise.resolve(explicitResetSeed),
)).resolves.toEqual({ provider: 'alpha', model: 'shared' })
const nonReasoningOutput = result.terminal.output.length
result.terminal.send('/model')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Select model')
})
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[Z')
result.terminal.send('\r')
await tick()
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model selected: beta/shared.')
result.terminal.send('/model beta/shared')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model is already beta/shared.')
})
await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
result.terminal.send('/model alpha/a1')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Reasoning effort: Low.')
})
const inheritedEffort: LlmCallConfig = {
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('max'),
}
await expect(agentEvents(result.ctx, result.agent).waterfall(
'agent/request',
0,
0,
inheritedEffort,
new AbortController().signal,
() => Promise.resolve(inheritedEffort),
)).resolves.toEqual({ provider: 'beta', model: 'shared' })
result.agent.status = 'running'
const runningSelectorOutput = result.terminal.output.length
result.terminal.send('/model')
@@ -2544,13 +2673,18 @@ describe('pi-tui chat lifecycle and transcript', () => {
const output = result.terminal.output.slice(runningSelectorOutput)
expect(output).toContain('Select model')
expect(output).toContain('alpha/a1')
expect(output).toContain('Alpha One — Fast — current')
expect(output).toContain('Alpha One — Fast — Low — current')
expect(output).toContain('Beta One — High')
})
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[Z')
await tick()
expect(result.terminal.output).toContain('Beta One — Max')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Model selected: beta/b1')
expect(result.terminal.output).toContain('Reasoning effort: Max.')
expect(result.agent.sent).toEqual([])
expect(result.agent.steered).toEqual([])
initialContext.resolve({ contextWindow: 100 })
@@ -2569,8 +2703,12 @@ 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('b1 ')
expect(result.terminal.output).toContain('b1 max ')
expect(result.terminal.output).toContain('25% context tools:collapsed')
result.terminal.send('/status')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('beta/b1 (effort max; reasoning blocks shown)')
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
@@ -2578,7 +2716,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
const request = await agentEvents(result.ctx, result.agent).waterfall(
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(seed),
)
expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
expect(request).toEqual({
provider: 'beta',
model: 'b1',
reasoningEffort: ReasoningEffortId('max'),
temperature: 0.2,
})
await dispose(result)
})
@@ -2588,7 +2731,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] },
beforeMount(session) {
session.append('request/header', {
header: { config: { provider: 'beta', model: 'private' } },
header: {
config: {
provider: 'beta',
model: 'private',
reasoningEffort: ReasoningEffortId('ultra'),
},
},
reason: 'initial',
})
},
@@ -2598,15 +2747,36 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(resumed.terminal.output).toContain('Select model')
expect(resumed.terminal.output).toContain('beta/private')
expect(resumed.terminal.output).toContain('private — current')
expect(resumed.terminal.output).toContain('private — ultra — current')
resumed.terminal.send('\x1b')
await tick()
resumed.terminal.send('/model beta/private')
resumed.terminal.send('\r')
await tick()
expect(resumed.terminal.output).toContain('with reasoning effort ultra')
await dispose(resumed)
const resumedDefault = await setup({
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'default', name: 'Default Model' }],
},
beforeMount(session) {
session.append('request/header', {
header: { config: { provider: 'alpha', model: 'default' } },
reason: 'initial',
})
},
})
expect(resumedDefault.terminal.output).toContain('default • main-session')
await dispose(resumedDefault)
const unset = await setup({
agentOptions: {},
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
resolveModelContext: () => Promise.resolve(undefined),
resolveModelInfo: () => Promise.resolve({}),
},
})
unset.terminal.send('/model')
@@ -2638,7 +2808,7 @@ 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')),
resolveModelInfo: () => Promise.reject(new Error('capacity offline')),
},
})
failed.terminal.send('/model')
@@ -2648,6 +2818,20 @@ describe('pi-tui chat lifecycle and transcript', () => {
})
expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline')
await dispose(failed)
const reasoningFailed = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [{ provider: 'deepseek', id: 'model-1', name: 'Model One' }],
resolveModelInfo: () => Promise.reject(new Error('reasoning metadata offline')),
},
})
reasoningFailed.terminal.send('/model')
reasoningFailed.terminal.send('\r')
await vi.waitFor(() => {
expect(reasoningFailed.terminal.output).toContain('Could not read the model catalog: reasoning metadata offline')
})
await dispose(reasoningFailed)
})
it('does not render a model catalog that resolves after TUI disposal', async () => {
@@ -2689,7 +2873,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
resolveModelContext: () => context.promise,
resolveModelInfo: () => context.promise.then(value => ({ context: value })),
},
})
await contextResult.controller.dispose()