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:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 3d64de9f0703838cad10f8e04665ec4b985d00dc
|
||||
README.zh.md: 5cf41dd76c7c28cd2d605466c7c10cfe1c1dd958
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
|
||||
README.md: 84ed3de78469ab778118ee5599acfd8476b0ecd1
|
||||
README.zh.md: 771b89a91077db7543713b4ce1b5fce0c30c2a16
|
||||
|
||||
@@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
|
||||
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
|
||||
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. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. 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.
|
||||
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. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. 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.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; 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.
|
||||
|
||||
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
|
||||
|
||||
@@ -24,13 +24,13 @@ 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, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. 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 pair 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.
|
||||
|
||||
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
|
||||
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
|
||||
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
|
||||
`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
@@ -49,7 +49,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
|
||||
| `maxResumeOptions` | `8` | Visible sessions in the resume selector |
|
||||
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
|
||||
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
|
||||
| `modelDialogWidth` | `72` | Model-selector width in columns |
|
||||
| `modelDialogWidth` | `76` | Model-selector width in columns |
|
||||
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
|
||||
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
|
||||
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
|
||||
@@ -116,7 +116,7 @@ The fixed instruction is part of the stable system-prompt prefix and is reusable
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing.
|
||||
The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model route in prompt variables and the selected provider/model/reasoning-effort target in request routing.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend
|
||||
|
||||
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
|
||||
|
||||
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把最新的 `todo/write` 计划保留在编辑器上方,并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会比较 token-meter 压力与当前路由的 `ctx.llm.resolveModelContext()`;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型和 reasoning 状态。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。
|
||||
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把最新的 `todo/write` 计划保留在编辑器上方,并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。
|
||||
|
||||
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。
|
||||
|
||||
@@ -24,13 +24,13 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso
|
||||
|
||||
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/reasoning`、`/tools`、`/redraw`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片把长主体折叠为可配置的头尾预览;Ctrl+O 在预览与完整输出之间切换所有卡片。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
|
||||
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Enter 选择,Escape 关闭。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一组值;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 关闭。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
|
||||
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。
|
||||
|
||||
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会比较 token-meter 压力与当前路由的 `ctx.llm.resolveModelContext()`(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。
|
||||
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。
|
||||
|
||||
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
|
||||
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
|
||||
|
||||
`/resume` 会针对当前工作区打开全 viewport 键盘选择器,而非居中对话框。获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、cwd 不匹配或日志所记提供方没有当前适配器的会话仍会显示,但不可选择。选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并调用由宿主持有的可选 `TuiRuntime.handoffResume`;存在 `process.execve` 时,发布的 `dsh` 宿主会对 app 执行 dispose(资源释放)并替换自身进程。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。
|
||||
|
||||
@@ -49,7 +49,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
| `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 |
|
||||
| `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 |
|
||||
| `questionDialogMaxHeight` | `20` | 问题面板最大行数 |
|
||||
| `modelDialogWidth` | `72` | 模型选择器宽度(列数) |
|
||||
| `modelDialogWidth` | `76` | 模型选择器宽度(列数) |
|
||||
| `modelDialogMaxHeight` | `20` | 模型选择器最大行数 |
|
||||
| `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 |
|
||||
| `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 |
|
||||
@@ -116,7 +116,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`/model` 命令文本和键盘选择器输入均不会记录或发送。新步骤会在提示词变量和请求路由中同时收到所选提供方/模型对。
|
||||
`/model` 命令文本和键盘选择器输入均不会记录或发送。新步骤会在提示词变量中收到所选提供方/模型路由,并在请求路由中收到所选提供方/模型/推理强度目标。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type EditorTheme,
|
||||
type Focusable,
|
||||
type MarkdownTheme,
|
||||
type SelectItem,
|
||||
type SelectListTheme,
|
||||
type SlashCommand,
|
||||
type Terminal,
|
||||
@@ -53,6 +54,8 @@ import { assertNever, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock,
|
||||
LlmModelInfo,
|
||||
LlmModelReasoningInfo,
|
||||
ReasoningEffortId,
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
@@ -233,7 +236,7 @@ const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
|
||||
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
|
||||
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
|
||||
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
|
||||
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
|
||||
@@ -356,7 +359,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
|
||||
maxResumeOptions: config?.maxResumeOptions ?? 8,
|
||||
questionDialogWidth: config?.questionDialogWidth ?? 200,
|
||||
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 72,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 76,
|
||||
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
|
||||
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
@@ -580,15 +583,39 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'
|
||||
interface ModelChoice extends AgentLlmTarget {
|
||||
modelName: string
|
||||
description?: string
|
||||
reasoning?: LlmModelReasoningInfo
|
||||
}
|
||||
|
||||
interface ModelDialogSelection {
|
||||
choice: ModelChoice
|
||||
reasoningEffort: ReasoningEffortId | undefined
|
||||
}
|
||||
|
||||
function targetLabel(target: AgentLlmTarget): string {
|
||||
return `${target.provider}/${target.model}`
|
||||
}
|
||||
|
||||
function compactTargetLabel(target: AgentLlmTarget): string {
|
||||
return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}`
|
||||
}
|
||||
|
||||
function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined {
|
||||
if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default'
|
||||
return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort
|
||||
}
|
||||
|
||||
function initialTarget(agent: Agent): AgentLlmTarget | undefined {
|
||||
const logged = agent.session.requestHeader()?.config
|
||||
if (logged !== undefined) return { provider: logged.provider, model: logged.model }
|
||||
if (logged !== undefined) {
|
||||
if (logged.reasoningEffort === undefined) {
|
||||
return { provider: logged.provider, model: logged.model }
|
||||
}
|
||||
return {
|
||||
provider: logged.provider,
|
||||
model: logged.model,
|
||||
reasoningEffort: logged.reasoningEffort,
|
||||
}
|
||||
}
|
||||
if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
|
||||
return { provider: agent.options.provider, model: agent.options.model }
|
||||
}
|
||||
@@ -607,11 +634,15 @@ async function readModelChoices(
|
||||
) {
|
||||
models.push({ provider: provider.id, id: current.model, name: current.model })
|
||||
}
|
||||
return models.map((model): ModelChoice => ({
|
||||
provider: provider.id,
|
||||
model: model.id,
|
||||
modelName: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
return Promise.all(models.map(async (model): Promise<ModelChoice> => {
|
||||
const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning
|
||||
return {
|
||||
provider: provider.id,
|
||||
model: model.id,
|
||||
modelName: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
}
|
||||
}))
|
||||
}))
|
||||
return groups.flat()
|
||||
@@ -1226,24 +1257,40 @@ function renderDialog(
|
||||
|
||||
class ModelDialog implements Component {
|
||||
private readonly list: SelectList
|
||||
private readonly items: Map<string, SelectItem>
|
||||
private readonly choices: Map<string, ModelChoice>
|
||||
private readonly efforts: Map<string, ReasoningEffortId | undefined>
|
||||
private readonly currentValue: string | undefined
|
||||
|
||||
constructor(
|
||||
choices: readonly ModelChoice[],
|
||||
current: AgentLlmTarget | undefined,
|
||||
maxVisible: number,
|
||||
private readonly palette: Palette,
|
||||
done: (choice: ModelChoice) => void,
|
||||
done: (selection: ModelDialogSelection) => void,
|
||||
cancel: () => void,
|
||||
) {
|
||||
this.list = new SelectList(choices.map(choice => ({
|
||||
value: targetLabel(choice),
|
||||
label: displayText(targetLabel(choice)),
|
||||
description: [
|
||||
displayText(choice.modelName),
|
||||
...choice.description === undefined ? [] : [displayText(choice.description)],
|
||||
...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [],
|
||||
].join(' — '),
|
||||
})), maxVisible, dialogSelectTheme(palette))
|
||||
this.items = new Map()
|
||||
this.choices = new Map()
|
||||
this.efforts = new Map()
|
||||
this.currentValue = current === undefined ? undefined : targetLabel(current)
|
||||
for (const choice of choices) {
|
||||
const value = targetLabel(choice)
|
||||
const isCurrent = current?.provider === choice.provider && current.model === choice.model
|
||||
this.choices.set(value, choice)
|
||||
this.efforts.set(
|
||||
value,
|
||||
isCurrent
|
||||
? current.reasoningEffort ?? choice.reasoning?.defaultEffort
|
||||
: choice.reasoning?.defaultEffort,
|
||||
)
|
||||
this.items.set(value, {
|
||||
value,
|
||||
label: displayText(value),
|
||||
description: this.describeChoice(choice, isCurrent),
|
||||
})
|
||||
}
|
||||
this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette))
|
||||
const currentIndex = current === undefined
|
||||
? 0
|
||||
: choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
|
||||
@@ -1252,17 +1299,58 @@ 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
|
||||
done(selected)
|
||||
done({
|
||||
choice: selected,
|
||||
reasoningEffort: this.efforts.get(item.value),
|
||||
})
|
||||
}
|
||||
this.list.onCancel = cancel
|
||||
}
|
||||
|
||||
private describeChoice(choice: ModelChoice, isCurrent: boolean): string {
|
||||
const selectedEffort = this.efforts.get(targetLabel(choice))
|
||||
const effort = choice.reasoning?.efforts.find(candidate => candidate.id === selectedEffort)
|
||||
const effortLabel = selectedEffort === undefined
|
||||
? choice.reasoning === undefined ? undefined : 'provider default'
|
||||
: effort?.name ?? selectedEffort
|
||||
return [
|
||||
displayText(choice.modelName),
|
||||
...choice.description === undefined ? [] : [displayText(choice.description)],
|
||||
...effortLabel === undefined ? [] : [displayText(effortLabel)],
|
||||
...isCurrent ? ['current'] : [],
|
||||
].join(' — ')
|
||||
}
|
||||
|
||||
private cycleReasoningEffort(): void {
|
||||
const selectedItem = this.list.getSelectedItem()
|
||||
/* v8 ignore next -- the dialog is opened only for a non-empty catalog. */
|
||||
if (selectedItem === null) return
|
||||
const choice = this.choices.get(selectedItem.value)
|
||||
if (choice?.reasoning === undefined) return
|
||||
const current = this.efforts.get(selectedItem.value)
|
||||
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
|
||||
item.description = this.describeChoice(choice, selectedItem.value === this.currentValue)
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.list.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.list.handleInput(data)
|
||||
if (matchesKey(data, Key.shift(Key.tab))) {
|
||||
this.cycleReasoningEffort()
|
||||
} else {
|
||||
this.list.handleInput(data)
|
||||
}
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
@@ -1271,7 +1359,7 @@ class ModelDialog implements Component {
|
||||
return renderDialog('Select model', [
|
||||
...this.list.render(innerWidth),
|
||||
'',
|
||||
this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'),
|
||||
this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'),
|
||||
], width, this.palette)
|
||||
}
|
||||
}
|
||||
@@ -1924,7 +2012,7 @@ export function createTuiChat(
|
||||
() => sessionTitle ?? config.welcome,
|
||||
palette,
|
||||
resolved.color && resolved.truecolor,
|
||||
() => target.current?.model,
|
||||
() => target.current === undefined ? undefined : compactTargetLabel(target.current),
|
||||
)
|
||||
const footer = new FooterComponent(
|
||||
agent,
|
||||
@@ -1932,7 +2020,7 @@ export function createTuiChat(
|
||||
() => toolsExpanded,
|
||||
() => tokens,
|
||||
runtime.formatCwd,
|
||||
() => target.current?.model,
|
||||
() => target.current === undefined ? undefined : compactTargetLabel(target.current),
|
||||
() => contextWindow === undefined
|
||||
? undefined
|
||||
: Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)),
|
||||
@@ -2005,8 +2093,8 @@ export function createTuiChat(
|
||||
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),
|
||||
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
|
||||
info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const),
|
||||
(error: unknown) => ({ kind: 'error', error } as const),
|
||||
)
|
||||
contextResolution = resolution
|
||||
@@ -2022,14 +2110,31 @@ export function createTuiChat(
|
||||
}
|
||||
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)}.`)
|
||||
const selectModel = (
|
||||
selected: ModelChoice,
|
||||
explicitReasoning?: { effort: ReasoningEffortId | undefined },
|
||||
): void => {
|
||||
const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model
|
||||
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)}`}.`)
|
||||
return
|
||||
}
|
||||
target.current = { provider: selected.provider, model: selected.model }
|
||||
target.current = {
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
}
|
||||
resolveContextWindow(target.current)
|
||||
appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
|
||||
const reasoning = targetReasoningLabel(selected, reasoningEffort)
|
||||
appendNotice([
|
||||
`Model selected: ${targetLabel(selected)}.`,
|
||||
...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`],
|
||||
'New steps will use it.',
|
||||
].join(' '))
|
||||
}
|
||||
|
||||
const showModelSelector = (choices: readonly ModelChoice[]): void => {
|
||||
@@ -2045,9 +2150,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() },
|
||||
),
|
||||
@@ -2606,12 +2711,17 @@ export function createTuiChat(
|
||||
const steps = events.filter(event => event.type === 'step/start').length
|
||||
const toolCalls = events.filter(event => event.type === 'tool/call').length
|
||||
const model = target.current === undefined ? 'unset' : displayText(targetLabel(target.current))
|
||||
const effort = target.current === undefined
|
||||
? 'unset'
|
||||
: target.current.reasoningEffort === undefined
|
||||
? 'default'
|
||||
: displayText(target.current.reasoningEffort)
|
||||
const groups: readonly (readonly StatusCardRow[])[] = [
|
||||
[
|
||||
['Session', displayText(agent.session.id)],
|
||||
['Title', displayText(sessionTitle ?? 'untitled')],
|
||||
['Directory', displayText(cwd)],
|
||||
['Model', `${model} ${palette.dim(`(reasoning ${showReasoning ? 'shown' : 'hidden'})`)}`],
|
||||
['Model', `${model} ${palette.dim(`(effort ${effort}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'})`)}`],
|
||||
],
|
||||
[
|
||||
['Agent', [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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| " "
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user