fix(llm): honor model metadata cancellation and defaults

This commit is contained in:
Yichen Jiang
2026-07-26 14:23:06 +08:00
parent 8b34ccd994
commit ac792bfd78
12 changed files with 96 additions and 38 deletions

View File

@@ -22,7 +22,7 @@ When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. Models without selectable effort metadata ignore Shift+Tab; the selector renders the exact advertised list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.

View File

@@ -586,6 +586,11 @@ interface ModelChoice extends AgentLlmTarget {
reasoning?: LlmModelReasoningInfo
}
interface ModelDialogSelection {
choice: ModelChoice
reasoningEffort: ReasoningEffortId | undefined
}
function targetLabel(target: AgentLlmTarget): string {
return `${target.provider}/${target.model}`
}
@@ -1262,7 +1267,7 @@ class ModelDialog implements Component {
current: AgentLlmTarget | undefined,
maxVisible: number,
private readonly palette: Palette,
done: (choice: ModelChoice) => void,
done: (selection: ModelDialogSelection) => void,
cancel: () => void,
) {
this.items = new Map()
@@ -1294,10 +1299,9 @@ class ModelDialog implements Component {
const selected = choices.find(choice => targetLabel(choice) === item.value)
/* v8 ignore next -- SelectList only returns values built from `choices`. */
if (selected === undefined) return
const effort = this.efforts.get(item.value)
done({
...selected,
...effort === undefined ? {} : { reasoningEffort: effort },
choice: selected,
reasoningEffort: this.efforts.get(item.value),
})
}
this.list.onCancel = cancel
@@ -1324,11 +1328,13 @@ class ModelDialog implements Component {
const choice = this.choices.get(selectedItem.value)
if (choice?.reasoning === undefined) return
const current = this.efforts.get(selectedItem.value)
const currentIndex = choice.reasoning.efforts.findIndex(effort => effort.id === current)
const next = choice.reasoning.efforts[(currentIndex + 1) % choice.reasoning.efforts.length]
/* v8 ignore next -- validated reasoning metadata always carries at least one effort. */
if (next === undefined) return
this.efforts.set(selectedItem.value, next.id)
const efforts: Array<ReasoningEffortId | undefined> = [
...choice.reasoning.defaultEffort === undefined ? [undefined] : [],
...choice.reasoning.efforts.map(effort => effort.id),
]
const currentIndex = efforts.indexOf(current)
const next = efforts[(currentIndex + 1) % efforts.length]
this.efforts.set(selectedItem.value, next)
const item = this.items.get(selectedItem.value)
/* v8 ignore next -- items and choices are constructed from the same values. */
if (item === undefined) return
@@ -2118,10 +2124,14 @@ export function createTuiChat(
}
resolveContextWindow(target.current)
const selectModel = (selected: ModelChoice): void => {
const selectModel = (
selected: ModelChoice,
explicitReasoning?: { effort: ReasoningEffortId | undefined },
): void => {
const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model
const reasoningEffort = selected.reasoningEffort
?? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort)
const reasoningEffort = explicitReasoning === undefined
? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort)
: explicitReasoning.effort
if (sameRoute && target.current?.reasoningEffort === reasoningEffort) {
const reasoning = targetReasoningLabel(selected, reasoningEffort)
appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`)
@@ -2154,9 +2164,9 @@ export function createTuiChat(
target.current,
resolved.maxModelOptions,
palette,
(selected) => {
(selection) => {
void session.close()
selectModel(selected)
selectModel(selection.choice, { effort: selection.reasoningEffort })
},
() => { void session.close() },
),

View File

@@ -26,9 +26,9 @@ buffer
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 — Off │ "
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
style 8-8 fg=bright-blue
style 10-64 fg=bright-blue inverse
style 10-77 fg=bright-blue inverse
style 83-83 fg=bright-blue
16| " │ │ "
style 8-8 fg=bright-blue

View File

@@ -26,9 +26,9 @@ buffer
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 — High │ "
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
style 8-8 fg=bright-blue
style 36-65 fg=bright-black
style 36-77 fg=bright-black
style 83-83 fg=bright-blue
16| " │ │ "
style 8-8 fg=bright-blue

View File

@@ -1,25 +1,27 @@
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
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-pro off • main-session"
style 1-36 dim
2| " deepseek-v4-pro • main-session"
style 1-32 dim
3| <blank>
4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: Off. New steps will use it. "
style 1-87 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 off /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-45 dim
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

@@ -632,7 +632,7 @@ describe('TUI terminal-state snapshots', () => {
await harness.terminal.dispose()
})
it('pins the model selector and selection notice', async () => {
it('pins the model selector, effort cycling, and provider-default selection', async () => {
const harness = await setupSnapshot({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
@@ -640,7 +640,7 @@ describe('TUI terminal-state snapshots', () => {
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
resolveModelInfo: () => Promise.resolve({
resolveModelInfo: (_provider, model) => Promise.resolve({
context: { contextWindow: 128_000 },
reasoning: {
efforts: [
@@ -648,7 +648,9 @@ describe('TUI terminal-state snapshots', () => {
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId('high'),
...model === 'deepseek-v4-flash'
? { defaultEffort: ReasoningEffortId('high') }
: {},
},
}),
},
@@ -662,6 +664,8 @@ describe('TUI terminal-state snapshots', () => {
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, () => {

View File

@@ -2387,6 +2387,36 @@ describe('pi-tui chat lifecycle and transcript', () => {
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')