diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml index 58f563774..980c7ee99 100644 --- a/.github/ISSUE_TEMPLATE/01-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml @@ -1,7 +1,7 @@ name: Bug report description: Report a reproducible problem with the Desktop app, CLI, or source build. title: "[Bug]: " -labels: ["bug"] +labels: ["bug", "needs-triage"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.yml b/.github/ISSUE_TEMPLATE/02-feature-request.yml index b2a5875c8..7d8c4d279 100644 --- a/.github/ISSUE_TEMPLATE/02-feature-request.yml +++ b/.github/ISSUE_TEMPLATE/02-feature-request.yml @@ -1,7 +1,7 @@ name: Feature request description: Suggest an improvement to the Desktop app, CLI, or source distribution. title: "[Feature]: " -labels: ["enhancement"] +labels: ["enhancement", "needs-triage"] body: - type: dropdown id: product diff --git a/.github/ISSUE_TEMPLATE/03-question.yml b/.github/ISSUE_TEMPLATE/03-question.yml index 6ec6aef52..a200f16c2 100644 --- a/.github/ISSUE_TEMPLATE/03-question.yml +++ b/.github/ISSUE_TEMPLATE/03-question.yml @@ -1,12 +1,13 @@ -name: Usage question -description: Ask about the Desktop app, CLI, or building from source. +name: Usage or repository policy question +description: Ask about the Desktop app, CLI, source builds, or contribution policy. title: "[Question]: " -labels: ["question"] +labels: ["question", "needs-triage"] body: - type: dropdown id: product attributes: label: Product or interface + description: Leave blank if unsure or asking about repository policy. options: - Desktop app - CLI - interactive TUI @@ -14,7 +15,7 @@ body: - CLI - ACP - Source build or repository tooling validations: - required: true + required: false - type: textarea id: question attributes: @@ -25,6 +26,7 @@ body: id: platform attributes: label: Platform + description: Leave blank if unknown or not applicable. options: - macOS - Windows @@ -32,11 +34,11 @@ body: - WSL - Multiple platforms validations: - required: true + required: false - type: textarea id: context attributes: - label: Version and what you have tried - description: Include documentation you followed and relevant redacted output. Do not include keys or account data. + label: Version or policy context + description: For usage questions, include your version and what you tried. For policy questions, link the relevant policy. Do not include keys or account data. validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e224f2597..27fe16a2c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Contribution policy + url: https://github.com/MiniMax-AI/minimax-code/blob/main/CONTRIBUTING.md + about: Everyone may report issues and suggest improvements. Pull requests are accepted only from repository collaborators; external PRs are not accepted. - name: CLI quick start and troubleshooting url: https://agent.minimax.io/docs/cli/quick-start about: Installation, account regions, first tasks, and links to troubleshooting. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml index 1923400d2..1982f5826 100644 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -1,6 +1,7 @@ name: Documentation issue description: Report missing, incorrect, or unclear documentation. title: "[Docs] " +labels: ["documentation", "needs-triage"] body: - type: input id: location diff --git a/README.md b/README.md index a3cfd6650..33aef03bb 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ mcode Supported API formats: `openai-completions`, `openai-responses`, and `anthropic-messages`. See the [model examples](docs/examples.md#2-choose-your-own-model) for environment variable setup, connection checks, and model overrides for a single run. +Providers added this way are stored under `custom_provider` in the active profile's `config.yaml`; this is the supported path for third-party or self-hosted endpoints, while `minimax_api` stays reserved for the official MiniMax API. Relays that require `Authorization: Bearer` on an Anthropic-compatible endpoint can set custom headers in `config.yaml`; see [Third-party relays and custom auth headers](docs/examples.md#third-party-relays-and-custom-auth-headers). + ### 3. Run your first task diff --git a/README_ZH.md b/README_ZH.md index 32d3ab2b9..64459aa64 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -98,6 +98,8 @@ mcode 支持 `openai-completions`、`openai-responses` 和 `anthropic-messages`。连接测试、单次模型切换及环境变量设置见 [模型示例](docs/examples.md#2-choose-your-own-model)。 +通过该命令添加的提供方保存在当前 profile `config.yaml` 的 `custom_provider` 下;第三方或自建端点一律走这条路径,`minimax_api` 保留给官方 MiniMax API。若中转端点只提供 Anthropic 兼容接口且要求 `Authorization: Bearer` 鉴权,可在 `config.yaml` 中为提供方配置自定义 headers;见[第三方中转与自定义鉴权头](docs/examples.md#third-party-relays-and-custom-auth-headers)。 + ### 3. 完成第一个任务 diff --git a/docs/examples.md b/docs/examples.md index 12a536569..da46a28c7 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -66,10 +66,65 @@ pnpm mcode provider list --json `--api-key-env` reads the current environment variable value and stores that value in the active profile's `config.yaml`; it does not save an environment-variable reference. The file still contains plaintext credentials. On POSIX systems, config writes and temporary copies use `0600`. When loading existing files, MCode removes group/other access while preserving the owner's permissions; already-private files such as `0400` or `0600` do not require a permission change. Loading fails if an unsafe main config cannot be restricted. Older migration backups are also checked, but inspection or repair failures produce a warning identifying the directory or backup that needs manual attention rather than preventing the main config from loading. Windows file modes do not provide equivalent ACL protection; restrict access to the profile directory using Windows permissions. +### Third-party relays and custom auth headers + +Providers created by `mcode provider add` are stored under `custom_provider` in the active profile's `config.yaml`. That tree is the supported home for any endpoint other than the official MiniMax API; `minimax_api` is reserved for the official endpoint and keeps its fixed authentication scheme. + +Authentication follows the API format: `openai-completions` and `openai-responses` send `Authorization: Bearer `, and `anthropic-messages` sends the key as `x-api-key`. A relay that expects Bearer authentication on an OpenAI-compatible endpoint therefore needs no extra configuration. If a relay only exposes an Anthropic-compatible endpoint (`/v1/messages`) and requires Bearer authentication, add explicit headers to the provider entry in `config.yaml`: + +```yaml +custom_provider: + my-relay: + options: + apiKey: sk-relay-key + baseURL: https://relay.example.com + headers: + Authorization: Bearer sk-relay-key + models: + MiniMax-M2: {} +``` + +`api` can be omitted and defaults to `anthropic-messages`. Configured headers are sent on both connection tests and conversation requests, and they are part of the connection-test fingerprint, so editing them retires a cached test verdict. The default `x-api-key` header is still sent alongside; the relay must tolerate rather than reject it. Header values are stored in plaintext like `apiKey` and are reported by name only in provider views. After editing the file, verify with `mcode provider list` and `mcode provider test `. + [Live acceptance](verification.md) separately verified MiniMax Token Plan and one configured BYOK provider. This is not a guarantee for every compatible service. ## 3. Search and image input +For a custom BYOK model, declare image input support explicitly when adding the +provider. Use this only if the selected provider endpoint and model accept images: + +```bash +pnpm mcode provider add --name my-vision-provider --base-url https://example.com/v1 \ + --api-format openai-completions --model my-vision-model \ + --api-key-env MCODE_PROVIDER_API_KEY --support-image --use +``` + +`--support-image` applies to every repeated `--model` and saves +`capabilities.support_image: true`. Without it, adding an unknown custom model +does not infer image support from its name. `--use` and `provider test` check +connectivity with a text request; they do not verify vision support. + +For an existing provider, close MCode and add the capability to the matching model +in the active profile's `config.yaml` (normally `~/.minimax/config.yaml`; see +[Accounts and data](installation.md#accounts-and-data) for profiles and overrides). +Merge this fragment into the existing provider and keep its other settings: + +```yaml +custom_provider: + my-vision-provider: + models: + my-vision-model: + capabilities: + support_image: true +``` + +The existing `modalities: { input: [text, image], output: [text] }` model setting +also enables image input. Either declaration is sufficient. To make a model +text-only again, remove `image` from `modalities.input` and remove or set +`capabilities.support_image` to `false`. Generic `attachment: true` alone does not +declare image input support. Restart MCode after editing configuration and select +the configured model with `/provider`, or use `exec --model` for a single run. + After signing in to MiniMax, try a task that explicitly requires search: > Use web_search to find the official Node.js test runner documentation. Summarize how to run tests and include the source URL. If the tool is unavailable, say so. diff --git a/docs/installation.md b/docs/installation.md index 843622a7a..1d8987a93 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -60,6 +60,52 @@ To locate data safely: For tests, explicitly set `MINIMAX_DATA_DIR` to a temporary directory to keep normal sessions separate. Use `$env:MINIMAX_DATA_DIR = 'C:\path\to\test-profile'` in PowerShell or `export MINIMAX_DATA_DIR=/path/to/test-profile` in a POSIX shell. +## macOS terminal shortcuts: Ghostty Option+M + +In the composer, `Alt+M` (`Option+M` on macOS) cycles permission modes through Ask, Auto, and Full access. Its binding ID is `composer.cycle-permission`. `Shift+Tab` toggles **Plan mode**, a separate setting. You can also use `/permission` to choose a permission mode and `/permission status` to inspect it without an Option shortcut. + +If `Option+M` inserts `µ` instead, the terminal is sending text rather than the expected Alt shortcut. MCode preserves literal `µ` as text; it cannot safely infer a permission change from that character. The terminal must forward the modifier, or you can choose another binding. + +### Configure Ghostty's Option key + +Ghostty's [`macos-option-as-alt`](https://ghostty.org/docs/config/reference#macos-option-as-alt) default is **keyboard-layout dependent** when unset, not always `false`. The current reference lists U.S. Standard and U.S. International as defaulting to `true`; other layouts default to `false`. Check the active macOS input source and the configuration loaded by your installed Ghostty version. + +Set this in your Ghostty configuration to send both Option keys as Alt: + +```ini +macos-option-as-alt = true +``` + +Use `left` or `right` instead of `true` to reserve only that Option key for shortcuts and retain Unicode composition on the other side. Use the configured side when testing `Option+M`. + +Follow Ghostty's [configuration locations and precedence](https://ghostty.org/docs/config#file-location): macOS-specific files load after XDG files, and later settings can override earlier ones. Inspect the loaded setting from the same Ghostty installation, without `--default`: + +```bash +ghostty +show-config | awk '/^macos-option-as-alt[[:space:]]*=/' +``` + +If `ghostty` is not on `PATH`, use the executable inside your installed app bundle, typically `/Applications/Ghostty.app/Contents/MacOS/ghostty`. An empty or omitted value is not proof of `false`; unset behavior depends on the layout. After editing, reload Ghostty's configuration from its menu (default `Cmd+Shift+,`) and open a new terminal. This is separate from MCode's `/reload` command. + +### Keep Option for text and customize MCode instead + +While idle, open `/hotkeys`, select `composer.cycle-permission`, press `Enter`, press `Ctrl+X`, then press `Enter` to save. `Ctrl+X` is unused by the current default MCode bindings; check any custom terminal or multiplexer bindings too. Do not choose `Ctrl+M`: terminals commonly encode it identically to `Enter`. + +Alternatively, merge this entry into `/tui/keybindings.json`, preserving any existing entries: + +```json +{ + "composer.cycle-permission": "ctrl+x" +} +``` + +For the default profile this is `~/.minimax/tui/keybindings.json`; [profiles and data-directory overrides](#accounts-and-data) change the path. Create the `tui` directory if needed. After editing the file, run `/reload` while idle with no pending interaction or queued message, or restart MCode. This replaces `Alt+M`; use `["alt+m", "ctrl+x"]` as the value to retain both bindings. `/hotkeys` shows the effective binding. + +### Verify the result + +With an idle composer and no open picker or permission prompt, note `/permission status`, press the configured shortcut, and check the status again. The mode should advance without inserting text. Restore your intended permission mode through `/permission` afterward. Confirm that `Shift+Tab` still toggles Plan mode and that pasting `µ` still inserts text. + +If it still fails, record `mcode --version`, Ghostty's version, active keyboard layout, which Option key you pressed, the setting above, the effective `/hotkeys` entry, and whether tmux or SSH is involved. Compare with a direct Ghostty session. MCode supports both legacy ESC-prefixed Alt input and extended keyboard reports; a shell's raw-key display alone does not establish what Ghostty sends after the TUI negotiates its keyboard protocol. + ## Update or remove Save your changes, fetch a reviewed revision with Git, then repeat the frozen install and build. A source installation does not automatically become an official npm installation. diff --git a/docs/maintainers.md b/docs/maintainers.md index 8056c4eef..23b709825 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -11,6 +11,29 @@ These are repository maintenance responsibilities, not an assertion that an account owns the product's copyright. Every PR that requires approval needs a reviewer other than the author, and security reports need a working private response path. Review maintainer additions and policy changes in a PR; do not silently grant repository access. +## Issue triage + +Published source covers the standalone CLI (interactive TUI, headless and ACP). The issue tracker also collects Desktop app feedback; accepting those reports does not expand the published source scope. + +Everyone may report bugs, request features, ask questions, and flag documentation problems. Code and documentation PRs are accepted only from repository collaborators. Opening an issue or having a proposal accepted does not grant collaborator access or permission to submit a PR. Do not use `good first issue` or `help wanted` while external PRs are closed. + +Use these labels independently: + +| Category | Labels and meaning | +| --- | --- | +| Type | `bug`: reported malfunction; `enhancement`: requested improvement; `documentation`: missing or incorrect docs; `question`: usage or project-policy question | +| Product | `desktop`: Desktop app; `cli`: standalone mcode, including TUI, headless, ACP and its source builds/tooling | +| Triage | `needs-triage`: awaiting maintainer assessment; `needs-info`: a maintainer has asked the reporter for specific missing information | + +1. Start with [needs-triage](https://github.com/MiniMax-AI/minimax-code/issues?q=is%3Aissue+is%3Aopen+label%3Aneeds-triage) and [unlabelled issues](https://github.com/MiniMax-AI/minimax-code/issues?q=is%3Aissue+is%3Aopen+no%3Alabel). Templates add a type and `needs-triage`; reports created through other routes may need both added manually. A type label describes the report and does not confirm a defect or commit to implementation. +2. Confirm one primary type and apply `desktop` or `cli` from the report. Product dropdowns stay in the body; maintainers apply product labels manually. Use both only when the report establishes that both are affected. Leave product labels unset for unclear reports or repository-policy questions. The existing `tui` label may supplement `cli` for interactive-terminal-specific issues; it does not cover headless or ACP. +3. If information is missing, ask for the specific version, reproduction, or expected behavior, replace `needs-triage` with `needs-info`, and retain any known type/product labels. When the reporter replies, remove `needs-info` and reassess. Do not automatically close issues for inactivity. +4. Once assessed, remove `needs-triage` and record the next step in a comment. Assign a collaborator when someone takes responsibility; classification alone does not promise a fix or release date. For duplicates, link the original issue before closing with `duplicate`. For declined requests, explain the reason before closing with `wontfix`. + +Route security reports through [Security](../SECURITY.md). Do not ask for secrets or unredacted logs in public issues. Label setup is a separate repository operation: ensure the labels above exist before merging template changes, since forms cannot create missing labels. The Feishu workflow sends notifications only; it does not perform triage. + +This small label set draws on the type and information-request handling in [VS Code's triage guide](https://github.com/microsoft/vscode/wiki/Issues-Triaging) and the `needs-triage`/`needs-info` states used by [Ruff](https://github.com/astral-sh/ruff/labels). Contribution eligibility follows this repository's policy. + ## Review and merge 1. Repository collaborators open an issue for substantial scope or submit a focused feature-branch PR. Other users may open issues to discuss ideas and proposals. Security details follow the private reporting process. diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 84dfe2708..ba6b7b428 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -46,4 +46,20 @@ Every collected diagnostic artifact, including prioritized session artifacts, is There is no raw-attachment upload option in this flow. Prompts, conversation text, tool arguments/results, command output, workspace excerpts, raw errors and unknown fields are excluded even if they contain no recognizable credential pattern. This intentionally reduces diagnostic detail: reproducing an exact response or inspecting an original stack is not possible from these uploads. Future raw attachments would require a separate, explicit review and consent surface describing their contents and scope. -The regression tests use temporary synthetic files and intercepted HTTP only. They decrypt the final automatic-report request as a receiver would, and unzip the actual feedback PUT body after real session-report collection. They do not validate production ingestion, retention policies, live services, or other platforms. +## Interactive startup model + +Use `mcode -m /` or +`mcode "Fix the failing tests" --model /` to select the +model for the startup Session. References use the same syntax as `exec --model`, +including `custom_provider:/`, model IDs containing `/`, and the +optional `#variant` suffix. The provider must already be configured and the model +must be available to Runtime; invalid references fail before the initial prompt runs. + +Without a resume option, this creates a new Session even when no prompt is given. +With `--session ` or `--continue`, it updates the opened Session's model before +submitting the prompt. The selection is saved with that Session, so later turns +and resumes keep it. It does not change the global default, and `/new` returns to +the configured default. Omitting `--model` preserves existing startup behavior. +The untargeted `--session` picker cannot be combined with `--model`; provide an ID +or use `--continue` instead. If opening or continuing a Session fails, the override +and initial prompt are not applied. diff --git a/packages/agent-modules/permission/src/tools/fs-permission.ts b/packages/agent-modules/permission/src/tools/fs-permission.ts index a31dca6a1..5d1fdc009 100644 --- a/packages/agent-modules/permission/src/tools/fs-permission.ts +++ b/packages/agent-modules/permission/src/tools/fs-permission.ts @@ -687,8 +687,10 @@ function isProtectedRuntimeRead( // at credentials must not turn its destination into a trusted asset root. const readableAsset = within(canonicalPath, path.join(canonicalRoot, 'skills')) || + within(canonicalPath, path.join(canonicalRoot, '.builtin-skills')) || (context.agentName != null && - within(canonicalPath, path.join(canonicalRoot, 'agents', context.agentName, 'workspace'))) || + (within(canonicalPath, path.join(canonicalRoot, 'agents', context.agentName, 'workspace')) || + within(canonicalPath, path.join(canonicalRoot, 'agents', context.agentName, 'skills')))) || isInternalWhitelistedPath(canonicalPath, { ...context, dataDir: canonicalRoot, diff --git a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts index 55a3c79bd..3707dfe0b 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts @@ -27,8 +27,13 @@ import type { CodexOAuthLoginOptions, CodexOAuthStatus, CopilotOAuthStatus, + UserModelInputView, } from "../../service/model-system/index.js"; +export type ProcessLocalModelInput = Omit & { + modalities?: { input?: readonly string[]; output?: readonly string[] }; +}; + export interface ProcessLocalMiniAppLaunch { readonly miniApp: MiniAppSurfaceSummary; /** Main-process-only launch coordinates. Never forward this object to a renderer. */ @@ -266,7 +271,7 @@ export interface LocalRuntimeApplication { baseUrl: string; apiKey: string; apiFormat?: string; - models?: readonly { modelId: string; displayName?: string }[]; + models?: readonly ProcessLocalModelInput[]; saveAndUse?: boolean; }): Promise; discoverCandidate(input: { @@ -282,21 +287,7 @@ export interface LocalRuntimeApplication { baseUrl: string; apiKey?: string; apiFormat?: string; - models?: readonly { - modelId: string; - displayName?: string; - configurationSource?: string; - enabled?: boolean; - attachment?: boolean; - reasoning?: boolean; - toolCall?: boolean; - temperature?: boolean; - modalities?: { - input?: readonly string[]; - output?: readonly string[]; - }; - limit?: { context?: number; output?: number }; - }[]; + models?: readonly ProcessLocalModelInput[]; }; modelId?: string; saveAndUse?: boolean; @@ -313,7 +304,7 @@ export interface LocalRuntimeApplication { apiKey?: string; apiFormat?: string; enabled?: boolean; - models?: readonly { modelId: string; displayName?: string }[]; + models?: readonly ProcessLocalModelInput[]; saveAndUse?: boolean; }): Promise; delete(input: { providerId: string }): Promise; diff --git a/packages/local-runtime-v2/src/application/session/process-local-application.ts b/packages/local-runtime-v2/src/application/session/process-local-application.ts index 071f39c7a..ca315cebb 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application.ts @@ -11,7 +11,10 @@ import type { } from '../../service/model-system/index.js'; import { watchGlobalEvents, watchProcessEvents } from '../events.js'; import type { ModelProviderApplication } from './model-provider-application.js'; -import type { LocalRuntimeApplication } from './process-local-application-contract.js'; +import type { + LocalRuntimeApplication, + ProcessLocalModelInput, +} from './process-local-application-contract.js'; import type { SessionReportCapability } from '../../service/session-system/index.js'; export interface ProcessLocalApplicationOptions { @@ -207,18 +210,7 @@ function toProviderRecord(provider: ModelProviderView): Record } function toUserModelInputs( - models: readonly { - modelId: string; - displayName?: string; - configurationSource?: string; - enabled?: boolean; - attachment?: boolean; - reasoning?: boolean; - toolCall?: boolean; - temperature?: boolean; - modalities?: { input?: readonly string[]; output?: readonly string[] }; - limit?: { context?: number; output?: number }; - }[], + models: readonly ProcessLocalModelInput[], ): UserModelInputView[] { return models.map(({ modalities, ...model }) => ({ ...model, diff --git a/packages/local-runtime-v2/src/service/model-system/contracts.ts b/packages/local-runtime-v2/src/service/model-system/contracts.ts index b9a56084b..30d3f3c54 100644 --- a/packages/local-runtime-v2/src/service/model-system/contracts.ts +++ b/packages/local-runtime-v2/src/service/model-system/contracts.ts @@ -414,6 +414,7 @@ export interface UserModelInputView { reasoning?: boolean; toolCall?: boolean; temperature?: boolean; + capabilities?: LocalModelConfig['capabilities']; modalities?: { input?: string[]; output?: string[] }; limit?: { context?: number; output?: number }; thinkingConfig?: { mode?: string; defaultValue?: string }; diff --git a/packages/local-runtime-v2/src/service/model-system/management/service-input.test.ts b/packages/local-runtime-v2/src/service/model-system/management/service-input.test.ts index 278831f3c..c59da9ed0 100644 --- a/packages/local-runtime-v2/src/service/model-system/management/service-input.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/management/service-input.test.ts @@ -14,6 +14,31 @@ import { } from './service-input.js'; describe('model provider input normalization', () => { + it('preserves declared capabilities on create and merges updates without losing limits', () => { + const created = modelsFromInputs([ + { + modelId: 'vision-model', + capabilities: { support_image: true, max_image_bytes_inline: 1024 }, + }, + ]); + expect(created['vision-model']?.capabilities).toEqual({ + support_image: true, + max_image_bytes_inline: 1024, + }); + const patch = [{ modelId: 'vision-model', capabilities: { support_image: false } }]; + for (const updated of [modelsFromInputs(patch, created), mergeModelsFromInputs(created, patch)]) { + expect(updated['vision-model']?.capabilities).toEqual({ + support_image: false, + max_image_bytes_inline: 1024, + }); + const renamed = mergeModelsFromInputs(updated, [ + { modelId: 'vision-model', displayName: 'Renamed' }, + ]); + expect(renamed['vision-model']?.capabilities).toEqual(updated['vision-model']?.capabilities); + } + expect(created['vision-model']?.capabilities?.support_image).toBe(true); + }); + it('normalizes API keys and formats', () => { expect(() => assertValidRawApiKey(undefined as unknown as string)).toThrow( LocalModelProviderError, diff --git a/packages/local-runtime-v2/src/service/model-system/management/service-input.ts b/packages/local-runtime-v2/src/service/model-system/management/service-input.ts index 9647a0292..e3ff4dc79 100644 --- a/packages/local-runtime-v2/src/service/model-system/management/service-input.ts +++ b/packages/local-runtime-v2/src/service/model-system/management/service-input.ts @@ -128,6 +128,9 @@ function modelFromInput( const nextModel: LocalModelConfig = { ...existing, ...modelMetadataFields(model), + ...(model.capabilities + ? { capabilities: { ...existing?.capabilities, ...model.capabilities } } + : {}), ...modelReasoningFields(model, effortOptions, defaultThinkingConfig), ...modelThinkingConfigFields(model, defaultThinkingConfig), ...modelEffortFields(effortOptions), diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts index 1187b5710..b8e4c32f3 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts @@ -1384,19 +1384,22 @@ describe('LocalModelResolver custom provider compat overrides', () => { }); // The incident was a wire-level symptom: pi chooses the system prompt role from the - // resolved compat, so these two cases pin the request pi would actually send. + // resolved compat, so these cases pin the request pi would actually send. const reasoningModelConfig: LocalModelConfig = { reasoning: true, thinking: { effortOptions: ['low', 'light', 'max'] }, }; - const systemPromptRoleFor = async (compat: LocalModelConfig['compat']) => { + const systemPromptRoleFor = async ( + compat: LocalModelConfig['compat'], + baseURL = 'https://gateway.example/v1', + ) => { const resolver = new LocalModelResolver({ byokConfigGetter: () => ({ custom_provider: { gateway: { api: 'openai-completions', - options: { apiKey: 'gateway-key', baseURL: 'https://gateway.example/v1' }, + options: { apiKey: 'gateway-key', baseURL }, models: { 'kimi-k2-thinking': { ...reasoningModelConfig, compat } }, }, }, @@ -1443,4 +1446,87 @@ describe('LocalModelResolver custom provider compat overrides', () => { it('still sends `developer` when the gateway declares nothing, reproducing the incident', async () => { expect(await systemPromptRoleFor(undefined)).toBe('developer'); }); + + it('keeps the system role for a thinking model on the Mistral API', async () => { + expect(await systemPromptRoleFor(undefined, 'https://api.mistral.ai/v1')).toBe('system'); + }); + + it.each([ + 'https://api.siliconflow.cn/v1', + 'https://api.siliconflow.com/v1', + 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', + 'https://coding.dashscope.aliyuncs.com/v1', + 'https://coding-intl.dashscope.aliyuncs.com/v1', + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', + 'https://trial.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + 'https://example-workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + 'https://example-workspace.cn-hongkong.maas.aliyuncs.com/compatible-mode/v1', + 'https://example-workspace.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', + 'https://example-workspace.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1', + 'https://example-workspace.eu-central-1.maas.aliyuncs.com/compatible-mode/v1', + 'https://example-workspace.us-east-1.maas.aliyuncs.com/compatible-mode/v1', + 'https://api.kimi.com/coding/v1', + 'https://api.kimi.ai/coding/v1', + ])('keeps the system role for a thinking model at %s', async (baseURL) => { + expect(await systemPromptRoleFor(undefined, baseURL)).toBe('system'); + }); + + it.each([ + ['https://api.openai.com/v1', 'developer'], + ['https://api.deepseek.com/v1', 'system'], + ['https://api.mistral.ai.example/v1', 'developer'], + ['https://gateway.example/api.mistral.ai/v1', 'developer'], + ['https://api.siliconflow.cn.example/v1', 'developer'], + ['https://gateway.example/api.siliconflow.cn/v1', 'developer'], + ['https://api.siliconflow.com.example/v1', 'developer'], + ['https://gateway.example/api.siliconflow.com/v1', 'developer'], + ['https://api.moonshot.cn/v1', 'system'], + ['https://api.moonshot.ai/v1', 'system'], + ['https://dashscope.aliyuncs.com.example/v1', 'developer'], + ['https://gateway.example/dashscope.aliyuncs.com/v1', 'developer'], + ['https://coding.dashscope.aliyuncs.com.example/v1', 'developer'], + ['https://token-plan.cn-beijing.maas.aliyuncs.com.example/v1', 'developer'], + ['https://gateway.example/token-plan.cn-beijing.maas.aliyuncs.com/v1', 'developer'], + ['https://example-workspace.unknown-region.maas.aliyuncs.com/v1', 'developer'], + ['https://example-workspace.cn-beijing.aliyuncs.com/v1', 'developer'], + ['https://api.kimi.com.example/v1', 'developer'], + ['https://gateway.example/api.kimi.com/coding/v1', 'developer'], + ])('preserves the system prompt role for %s', async (baseURL, role) => { + expect(await systemPromptRoleFor(undefined, baseURL)).toBe(role); + }); + + it('honors an explicit developer-role override on the Mistral API', async () => { + expect( + await systemPromptRoleFor({ supportsDeveloperRole: true }, 'https://api.mistral.ai/v1'), + ).toBe('developer'); + }); + + it.each([ + 'https://api.siliconflow.cn/v1', + 'https://api.siliconflow.com/v1', + 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'https://coding.dashscope.aliyuncs.com/v1', + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + 'https://example-workspace.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', + ])('honors an explicit developer-role override on %s', async (baseURL) => { + expect( + await systemPromptRoleFor( + { supportsDeveloperRole: true }, + baseURL, + ), + ).toBe('developer'); + }); + + it('honors an explicit developer-role override on a Kimi Coding endpoint', async () => { + expect( + await systemPromptRoleFor( + { supportsDeveloperRole: true }, + 'https://api.kimi.com/coding/v1', + ), + ).toBe('developer'); + }); }); diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts index 6138755a0..3f5981a8c 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts @@ -626,6 +626,19 @@ describe('resolveThinkingLevel', () => { }); describe('model capability helpers', () => { + it('requires explicit image input metadata instead of generic attachment support', () => { + expect(capabilitiesFromModelConfig({ attachment: true }).support_image).toBe(false); + expect( + capabilitiesFromModelConfig({ modalities: { input: ['text'], output: ['image'] } }).support_image, + ).toBe(false); + expect( + capabilitiesFromModelConfig({ modalities: { input: ['text', 'image'] } }).support_image, + ).toBe(true); + expect( + capabilitiesFromModelConfig({ capabilities: { support_image: false } }).support_image, + ).toBe(false); + }); + it('uses explicit capability flags and forced thinking modes', () => { expect( capabilitiesFromModelConfig({ diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts index f54b1f6a2..d74e9b03d 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts @@ -1,3 +1,4 @@ +import { logger } from '../../../../infra/logging/index.js'; import { PiTurnRunner, type LLMModelConfig, @@ -633,6 +634,16 @@ async function settleNetworkStop( safetyWriter: LocalOutputSafetyEventWriter, ): Promise { if (safetyWriter.terminalFailed) return; + logger.warn( + { + sessionId: input.sessionId, + turnId: input.turnId, + reviewOutcome: safetyWriter.reviewStopVariant === 'auth' ? 'auth_error' : 'unavailable', + outputSuppressed: true, + terminalStatus: 'completed', + }, + '[content-safety] output review stopped turn', + ); state.networkStopped = true; state.retractionVariant = safetyWriter.reviewStopVariant; state.approvedPartial = safetyWriter.getApprovedPartial(); diff --git a/packages/local-runtime-v2/test/unit/agent/storage/agent.repository.test.ts b/packages/local-runtime-v2/test/unit/agent/storage/agent.repository.test.ts index 3ff7958ab..8e44bae8f 100644 --- a/packages/local-runtime-v2/test/unit/agent/storage/agent.repository.test.ts +++ b/packages/local-runtime-v2/test/unit/agent/storage/agent.repository.test.ts @@ -169,6 +169,8 @@ async function seed(repository: DrizzleAgentRepository): Promise { } describe("Frozen Session deletion continuity", () => { + // This real-database create/delete/recreate sequence exceeded 5s on Windows CI. + // Keep the larger I/O budget local to this integration case. it("reads the persisted definition and renders after deleting and recreating its Agent", async () => { const { repository, database, dataDir } = await createRepository(); const agentService = createTestAgentService({ @@ -275,7 +277,7 @@ describe("Frozen Session deletion continuity", () => { const afterRecreate = await read(); expect(afterRecreate).toEqual(afterDelete); expect(afterRecreate.corePrompt).not.toContain("NEW_AGENT_PROMPT"); - }); + }, process.platform === "win32" ? 15_000 : 5_000); }); describe("Local Agent reference resolver integration", () => { diff --git a/packages/local-runtime/src/content-safety/api-v2.ts b/packages/local-runtime/src/content-safety/api-v2.ts index b849ead27..3122d8df4 100644 --- a/packages/local-runtime/src/content-safety/api-v2.ts +++ b/packages/local-runtime/src/content-safety/api-v2.ts @@ -16,6 +16,7 @@ import { managedBackendRoutingHeaders, type LocalRuntimeRoutingContext, } from '../runtime/routing-headers.js'; +import { createSafetyFailureReporter } from './diagnostics.js'; import { resolveSafetyApiBase } from './api-base.js'; /** biz-gateway uses V2 numeric scenes with its desktop HTTP envelope. */ @@ -50,26 +51,44 @@ export async function callLocalSafetyCheckV2(input: { const buildEnv = (input.buildEnv ?? getRuntimeBuildEnv)(); const region = (input.region ?? getRuntimeRegion)(); const token = input.authContext?.accessToken?.trim() || process.env.MAVIS_ACCESS_TOKEN?.trim(); - const result = await postSafetyCheckV2({ - url: `${resolveSafetyApiBase(region, buildEnv, input.testBaseURL)}/mavis/api/v2/content?require_auth=true`, - headers: { - 'User-Agent': 'MiniMaxAgent', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...managedBackendRoutingHeaders(input.routingContext, input.buildEnv ? buildEnv : undefined), - }, - body: { - scene: input.request.scene, - ...(input.request.content_text !== undefined - ? { content_text: input.request.content_text } - : {}), - ...(input.request.sessionId !== undefined ? { sessionId: input.request.sessionId } : {}), - ...(input.request.image_url !== undefined ? { image_url: input.request.image_url } : {}), - ...(input.request.files !== undefined ? { files: input.request.files } : {}), - }, - fetchImpl: input.fetchImpl, - signal: input.signal, - }); - return parseLocalSafetyCheckV2Result(result); + const url = `${resolveSafetyApiBase(region, buildEnv, input.testBaseURL)}/mavis/api/v2/content?require_auth=true`; + const reportFailure = createSafetyFailureReporter(url, 'v2', input.request.scene); + try { + const result = await postSafetyCheckV2({ + url, + headers: { + 'User-Agent': 'MiniMaxAgent', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...managedBackendRoutingHeaders( + input.routingContext, + input.buildEnv ? buildEnv : undefined, + ), + }, + body: { + scene: input.request.scene, + ...(input.request.content_text !== undefined + ? { content_text: input.request.content_text } + : {}), + ...(input.request.sessionId !== undefined ? { sessionId: input.request.sessionId } : {}), + ...(input.request.image_url !== undefined ? { image_url: input.request.image_url } : {}), + ...(input.request.files !== undefined ? { files: input.request.files } : {}), + }, + fetchImpl: input.fetchImpl, + signal: input.signal, + }); + return parseLocalSafetyCheckV2Result(result); + } catch (error) { + reportFailure( + error instanceof SafetyCheckV2Error + ? { + failureKind: error.kind, + statusCode: error.statusCode, + transportKind: error.transportKind, + } + : { failureKind: 'internal' }, + ); + throw error; + } } function parseLocalSafetyCheckV2Result(body: Record): LocalSafetyCheckV2Result { diff --git a/packages/local-runtime/src/content-safety/api.ts b/packages/local-runtime/src/content-safety/api.ts index d613cbb76..ba832b0fb 100644 --- a/packages/local-runtime/src/content-safety/api.ts +++ b/packages/local-runtime/src/content-safety/api.ts @@ -1,3 +1,8 @@ +import { + classifySafetyTransportError, + isSafetyCheckV2Record, +} from "@mavis/shared/safety-check-v2"; +import { createSafetyFailureReporter } from "./diagnostics.js"; import { getRuntimeBuildEnv, getRuntimeRegion, @@ -8,7 +13,6 @@ import { import { callTurnSafetyApi } from "./turn-api.js"; import { resolveSafetyApiBase } from "./api-base.js"; -import { logger } from "../common/logger.js"; import type { LocalRuntimeAuthContext } from "../runtime/model-resolver.js"; import { managedBackendRoutingHeaders, @@ -126,12 +130,7 @@ export async function callSafetyApi(input: { const accessToken = input.authContext?.accessToken?.trim() || process.env.MAVIS_ACCESS_TOKEN?.trim(); - // TODO(shared-oauth): Remove this local-only diagnostic after the content-safety - // resource server accepts mcode-public Bearer tokens in the joint test environment. - // Never add the token, request body/content, or response body to these fields. - const logLocalUpstream = - resolvedBuildEnv === "dev" || resolvedBuildEnv === "test"; - const startedAt = Date.now(); + const reportFailure = createSafetyFailureReporter(url, "v1", input.scene); let response: Response; try { response = await input.fetchImpl(url, { @@ -162,20 +161,10 @@ export async function callSafetyApi(input: { signal: AbortSignal.timeout(CONTENT_REVIEW_TIMEOUT_MS), }); } catch (error) { - if (logLocalUpstream) { - logger.info( - { - url, - method: "POST", - status: null, - scene: input.scene, - hasBearer: Boolean(accessToken), - durationMs: Date.now() - startedAt, - errorType: error instanceof Error ? error.name : typeof error, - }, - "[content-safety] upstream transport failure", - ); - } + reportFailure({ + failureKind: "transport", + transportKind: classifySafetyTransportError(error), + }); // fetch threw (offline / DNS / connection refused / TLS) or the 10s timeout // fired: we never reached a verdict. No gateway confirmation → fail closed. return { @@ -185,26 +174,21 @@ export async function callSafetyApi(input: { }; } - if (logLocalUpstream) { - logger.info( - { - url, - method: "POST", - status: response.status, - scene: input.scene, - hasBearer: Boolean(accessToken), - durationMs: Date.now() - startedAt, - upstreamTraceId: - response.headers.get("trace-id") ?? - response.headers.get("x-trace-id") ?? - response.headers.get("x-request-id") ?? - null, - }, - "[content-safety] upstream response", - ); + let text: string; + try { + text = await response.text(); + } catch (error) { + reportFailure({ + failureKind: "transport", + transportKind: classifySafetyTransportError(error), + statusCode: response.status, + }); + return { + pass: false, + reason: "Service response could not be read", + errorKind: "local_error", + }; } - - const text = await response.text(); if (!response.ok) { // Non-2xx from the gateway, split by class so an auth failure can never be a // bypass: @@ -215,6 +199,10 @@ export async function callSafetyApi(input: { // acceptable, so there is NO valid verdict on our side → `local_error` // (fail closed). Treating 401/403 as `api_error` would let an // unauthenticated user bypass every content gate. + reportFailure({ + failureKind: response.status === 401 || response.status === 403 ? "auth" : "http", + statusCode: response.status, + }); const error = readErrorText(text); return { pass: false, @@ -240,6 +228,7 @@ export async function callSafetyApi(input: { try { data = JSON.parse(text) as typeof data; } catch { + reportFailure({ failureKind: "response", statusCode: response.status }); // 2xx but the body is not JSON — we cannot read a verdict. Treat as no // gateway confirmation → fail closed on the input path. return { @@ -249,8 +238,9 @@ export async function callSafetyApi(input: { }; } - const pass = data.pass ?? data.safe; + const pass = isSafetyCheckV2Record(data) ? data.pass ?? data.safe : undefined; if (typeof pass !== "boolean") { + reportFailure({ failureKind: "response", statusCode: response.status }); // 2xx JSON but no boolean pass/safe field — again no usable verdict → fail // closed on the input path. return { @@ -262,6 +252,9 @@ export async function callSafetyApi(input: { const activeRegion = region(); const upstreamCode = typeof data.errorCode === "number" ? data.errorCode : undefined; + if (!pass && upstreamCode === UPSTREAM_CODE.SERVICE_UNAVAILABLE) { + reportFailure({ failureKind: "upstream", statusCode: upstreamCode }); + } return { pass, reason: diff --git a/packages/local-runtime/src/content-safety/diagnostics.ts b/packages/local-runtime/src/content-safety/diagnostics.ts new file mode 100644 index 000000000..0cd664630 --- /dev/null +++ b/packages/local-runtime/src/content-safety/diagnostics.ts @@ -0,0 +1,27 @@ +import { randomUUID } from 'node:crypto'; +import type { SafetyTransportKind } from '@mavis/shared/safety-check-v2'; +import { logger } from '../common/logger.js'; + +/** One bounded record per failed check, in every build. Never log raw upstream data. */ +export function createSafetyFailureReporter(url: string, apiVersion: 'v1' | 'v2', scene: number) { + const startedAt = Date.now(); + const reviewId = randomUUID(); + const endpointHost = new URL(url).hostname; + return (failure: { + failureKind: 'transport' | 'auth' | 'http' | 'response' | 'upstream' | 'internal'; + transportKind?: SafetyTransportKind; + statusCode?: number; + }): void => { + logger.warn( + { + reviewId, + endpointHost, + apiVersion, + scene, + durationMs: Date.now() - startedAt, + ...failure, + }, + '[content-safety] review failed', + ); + }; +} diff --git a/packages/local-runtime/src/permissions/facade.ts b/packages/local-runtime/src/permissions/facade.ts index a16a8fac0..c2cf742ea 100644 --- a/packages/local-runtime/src/permissions/facade.ts +++ b/packages/local-runtime/src/permissions/facade.ts @@ -608,7 +608,11 @@ export class LocalPermissionFacade { rules, workingDirectory, allowedWorkingPaths: [...pluginContext.directories], - sandboxAllowPaths: this.resolveSandboxAllowPaths(params.toolName, workingDirectory), + sandboxAllowPaths: this.resolveSandboxAllowPaths( + params.toolName, + workingDirectory, + params.agentName, + ), trustedExactWritePaths: params.trustedExactWritePaths, dataDir: this.deps.configGetter().dataDir, homeDir: homedir(), @@ -978,7 +982,11 @@ export class LocalPermissionFacade { // ------------------------------------------------------------------------- /** Built-in skill assets may be read without granting access to private runtime state. */ - private resolveSandboxAllowPaths(toolName: string, workingDirectory: string): string[] { + private resolveSandboxAllowPaths( + toolName: string, + workingDirectory: string, + agentName?: string, + ): string[] { // Read-only tool set parity with `READ_ONLY_TOOLS` // (`packages/local-runtime/src/permission/tools/fs-permission.ts`). // We don't import it directly to avoid coupling facade construction @@ -989,8 +997,11 @@ export class LocalPermissionFacade { const candidates: string[] = []; const dataDir = this.deps.configGetter().dataDir; - if (dataDir) candidates.push(path.resolve(dataDir, 'skills')); - candidates.push(path.resolve(homedir(), '.minimax', 'skills')); + for (const root of [dataDir, path.join(homedir(), '.minimax')]) { + if (!root) continue; + candidates.push(path.resolve(root, 'skills'), path.resolve(root, '.builtin-skills')); + if (agentName) candidates.push(path.resolve(root, 'agents', agentName, 'skills')); + } // De-duplicate (dataDir may already equal on a default install). const unique = Array.from(new Set(candidates)); if (!workingDirectory) return unique; diff --git a/packages/local-runtime/src/runtime/host.ts b/packages/local-runtime/src/runtime/host.ts index 4e85e601a..2774810bc 100644 --- a/packages/local-runtime/src/runtime/host.ts +++ b/packages/local-runtime/src/runtime/host.ts @@ -1,3 +1,4 @@ +import { logger } from "../common/logger.js"; import type { LocalEventWriter } from "../events/sink.js"; import { PiTurnRunner } from "@mavis/agent-core/pi-turn-runner"; import { RuntimeEventStatus, RuntimeEventType } from "@mavis/protocol"; @@ -414,6 +415,16 @@ export class LocalRuntimeHost { // writer, so keep it authoritative and do not append a synthetic // COMPLETED/network-notice outcome. if (safetyWriter.terminalFailed) break; + logger.warn( + { + sessionId: input.sessionId, + turnId: input.turnId, + reviewOutcome: "unavailable", + outputSuppressed: true, + terminalStatus: "completed", + }, + "[content-safety] output review stopped turn", + ); networkStopped = true; retractionVariant = "network"; // Capture the approved prefix so the orchestration can rewrite pi history diff --git a/packages/local-runtime/test/unit/child-bash-lifecycle.test.ts b/packages/local-runtime/test/unit/child-bash-lifecycle.test.ts index 47037d196..0586d51fe 100644 --- a/packages/local-runtime/test/unit/child-bash-lifecycle.test.ts +++ b/packages/local-runtime/test/unit/child-bash-lifecycle.test.ts @@ -36,6 +36,8 @@ function nodeCommand(script: string): string { const directories: string[] = []; const shutdowns: Array<() => Promise> = []; +// Windows CI has exceeded the 10s teardown budget for these SQLite/output fixtures. +// Keep the execution deadlines below separate from this bounded teardown budget. afterEach(async () => { // A terminal task can still be finalizing output or scheduling delivery. // Drain that work before closing SQLite, otherwise it can reopen the file @@ -45,7 +47,7 @@ afterEach(async () => { closeLocalRuntimeDb(path); await rm(path, { recursive: true, force: true }); } -}); +}, process.platform === 'win32' ? 30_000 : 10_000); async function fixture() { const dataDir = await mkdtemp(join(tmpdir(), 'child-bash-lifecycle-')); directories.push(dataDir); diff --git a/packages/local-runtime/test/unit/content-safety-api-fail-policy.test.ts b/packages/local-runtime/test/unit/content-safety-api-fail-policy.test.ts index 712dc321b..2a299ec14 100644 --- a/packages/local-runtime/test/unit/content-safety-api-fail-policy.test.ts +++ b/packages/local-runtime/test/unit/content-safety-api-fail-policy.test.ts @@ -276,46 +276,70 @@ describe('callSafetyApi fail-policy classification', () => { expect(headers.get('Token')).toBeNull(); }); - it('logs a sanitized upstream response in local test environments', async () => { - const log = vi.spyOn(logger, 'info').mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => - new Response(JSON.stringify({ code: 401, message: 'auth failed' }), { - status: 401, - headers: { - 'Content-Type': 'application/json', - 'Trace-Id': 'trace-content-safety-test', - }, - }), - ) as unknown as typeof fetch; - - await callSafetyApi({ - content: 'must-not-appear-in-log', - scene: SAFETY_SCENE.UserInput, - authContext: { accessToken: 'must-not-appear-token' }, - fetchImpl, - region, - buildEnv, - }); - - expect(log).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://matrix-test.example.invalid/mavis/api/v1/content', - method: 'POST', - status: 401, + it.each([ + ['auth', () => jsonResponse({ error: 'private-response' }, 401), 401], + ['http', () => jsonResponse({ error: 'private-response' }, 503), 503], + ['response', () => new Response('private-response'), 200], + ['response', () => jsonResponse(null), 200], + ['response', () => jsonResponse({ secret: 'private-response' }), 200], + [ + 'transport', + () => { + throw new TypeError('private-error https://secret.invalid/?token=private-token', { + cause: { code: 'ENOTFOUND' }, + }); + }, + undefined, + ], + [ + 'transport', + () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(new DOMException('private-error', 'TimeoutError')); + }, + }), + ), + 200, + ], + ] as const)( + 'logs redacted %s failures in production', + async (failureKind, response, statusCode) => { + const log = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const result = await callSafetyApi({ + content: 'private-content', scene: SAFETY_SCENE.UserInput, - hasBearer: true, - durationMs: expect.any(Number), - upstreamTraceId: 'trace-content-safety-test', - }), - '[content-safety] upstream response', - ); - const serialized = JSON.stringify(log.mock.calls); - expect(serialized).not.toContain('must-not-appear-token'); - expect(serialized).not.toContain('must-not-appear-in-log'); - }); + authContext: { accessToken: 'private-token' }, + attachments: [{ fileUrl: 'https://secret.invalid/private-file', fileName: 'private-name' }], + fetchImpl: async () => response(), + region, + buildEnv: () => 'prod', + }); + expect(result.pass).toBe(false); + expect(reviewBlocks(result)).toBe(failureKind !== 'http'); + expect(log).toHaveBeenCalledOnce(); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ + reviewId: expect.any(String), + endpointHost: 'agent.minimax.cn', + apiVersion: 'v1', + scene: SAFETY_SCENE.UserInput, + durationMs: expect.any(Number), + failureKind, + ...(statusCode === undefined ? { transportKind: 'dns' } : { statusCode }), + }), + '[content-safety] review failed', + ); + const serialized = JSON.stringify(log.mock.calls); + expect(serialized).not.toContain('private-'); + expect(serialized).not.toContain('secret.invalid'); + expect(serialized).not.toContain('https://'); + }, + ); - it('does not emit the temporary upstream diagnostic in production', async () => { - const log = vi.spyOn(logger, 'info').mockImplementation(() => {}); + it('does not log successful reviews in production', async () => { + const log = vi.spyOn(logger, 'warn').mockImplementation(() => {}); await callSafetyApi({ content: 'hello', diff --git a/packages/local-runtime/test/unit/content-safety-api-v2.test.ts b/packages/local-runtime/test/unit/content-safety-api-v2.test.ts index 5e057dba5..cedba065a 100644 --- a/packages/local-runtime/test/unit/content-safety-api-v2.test.ts +++ b/packages/local-runtime/test/unit/content-safety-api-v2.test.ts @@ -1,3 +1,4 @@ +import { logger } from '../../src/common/logger.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SAFETY_CHECK_V2_SCENE } from '@mavis/shared/safety-check-v2'; import { callLocalSafetyCheckV2 } from '../../src/content-safety/api-v2.js'; @@ -14,7 +15,10 @@ const defaults = { }; const fetchResponse = (body: unknown) => vi.fn().mockImplementation(async () => Response.json(body)); -afterEach(() => vi.unstubAllEnvs()); +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); describe('local biz-gateway SafetyCheckV2 client', () => { it('uses the V2 endpoint and scene with the current Bearer/routing headers', async () => { @@ -248,3 +252,81 @@ describe('local biz-gateway SafetyCheckV2 client', () => { }); }); }); + +describe('V2 production failure diagnostics', () => { + it.each([ + [{ cause: { code: 'ENOTFOUND' } }, 'dns'], + [{ cause: { code: 'ECONNREFUSED' } }, 'connect'], + [{ cause: { code: 'ERR_TLS_CERT_ALTNAME_INVALID' } }, 'tls'], + [{ name: 'TimeoutError' }, 'timeout'], + [{ cause: { code: 'UND_ERR_BODY_TIMEOUT' } }, 'timeout'], + [{ name: 'AbortError' }, 'aborted'], + [{ cause: { code: 'private-code' } }, 'unknown'], + ] as const)('retains only the transport category %j', async (details, transportKind) => { + const log = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const error = Object.assign( + new Error('private-error https://secret.invalid/?token=private-token'), + details, + ); + await expect( + callLocalSafetyCheckV2({ + ...defaults, + buildEnv: () => 'prod', + authContext: { accessToken: 'private-token' }, + request: { scene: 100, content_text: 'private-content', sessionId: 'private-session' }, + fetchImpl: async () => { + throw error; + }, + }), + ).rejects.toMatchObject({ kind: 'transport', transportKind }); + expect(log).toHaveBeenCalledOnce(); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ + reviewId: expect.any(String), + endpointHost: 'agent.minimax.cn', + apiVersion: 'v2', + scene: 100, + durationMs: expect.any(Number), + failureKind: 'transport', + transportKind, + }), + '[content-safety] review failed', + ); + expect(JSON.stringify(log.mock.calls)).not.toContain('private-'); + expect(JSON.stringify(log.mock.calls)).not.toContain('secret.invalid'); + }); + + it.each([ + ['auth', () => new Response('private-body', { status: 401 })], + ['http', () => new Response('private-body', { status: 503 })], + ['response', () => new Response('private-body')], + ['response', () => Response.json({ unexpected: 'private-body' })], + ['upstream', () => Response.json({ errorCode: 50200, error: 'private-body' })], + [ + 'transport', + () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(new DOMException('private-body', 'TimeoutError')); + }, + }), + ), + ], + ] as const)('logs failed %s reviews without response data', async (kind, response) => { + const log = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + await expect( + callLocalSafetyCheckV2({ + ...defaults, + buildEnv: () => 'prod', + fetchImpl: async () => response(), + }), + ).rejects.toMatchObject({ kind }); + expect(log).toHaveBeenCalledOnce(); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ failureKind: kind }), + '[content-safety] review failed', + ); + expect(JSON.stringify(log.mock.calls)).not.toContain('private-body'); + }); +}); diff --git a/packages/local-runtime/test/unit/local-permission-facade.test.ts b/packages/local-runtime/test/unit/local-permission-facade.test.ts index 29ebe6500..22afd2719 100644 --- a/packages/local-runtime/test/unit/local-permission-facade.test.ts +++ b/packages/local-runtime/test/unit/local-permission-facade.test.ts @@ -1709,6 +1709,111 @@ describe('LocalPermissionFacade', () => { }); describe('sandbox self-paths (dataDir auto-allow)', () => { + describe.each(['default', 'auto', 'bypassPermissions'] as const)( + 'skill resources in %s mode', + (permissionMode) => { + it.each(['read', 'grep', 'glob', 'list'])( + 'allows %s for global, builtin and active-agent skill resources', + async (toolName) => { + const classify = vi.fn(async (): Promise => ({ + kind: 'allow', + reasonLocalized: 'ok', + })); + const { facade, dataDir } = freshFacade({ + permissionMode, + cloudGateway: { classify }, + dataDirParent: homedir(), + }); + try { + for (const root of [ + path.join(dataDir, 'skills'), + path.join(dataDir, '.builtin-skills'), + path.join(dataDir, 'agents', 'fixture-agent', 'skills'), + ]) { + const resourceDir = path.join(root, 'fixture-skill', 'references'); + mkdirSync(resourceDir, { recursive: true }); + const resource = path.join(resourceDir, 'guide.txt'); + writeFileSync(resource, 'synthetic skill reference'); + const result = await facade.checkPermission({ + toolName, + agentName: 'fixture-agent', + input: { path: toolName === 'read' ? resource : resourceDir, pattern: '*' }, + }); + expect(result.behavior).toBe('allow'); + } + expect(classify).not.toHaveBeenCalled(); + } finally { + cleanup(dataDir); + } + }, + ); + }, + ); + + it.each(['default', 'auto'] as const)( + '%s mode keeps other agents and runtime state outside skill read access', + async (permissionMode) => { + const classify = vi.fn(async (): Promise => ({ + kind: 'allow', + reasonLocalized: 'ok', + })); + const { facade, dataDir } = freshFacade({ + permissionMode, + cloudGateway: { classify }, + dataDirParent: homedir(), + }); + try { + for (const relativePath of [ + 'agents/other-agent/skills/fixture-skill/references/guide.txt', + 'agents/fixture-agent/config.yaml', + 'agents/fixture-agent/skills-backup/guide.txt', + '.builtin-skills-backup/guide.txt', + ]) { + const resource = path.join(dataDir, relativePath); + mkdirSync(path.dirname(resource), { recursive: true }); + writeFileSync(resource, 'synthetic private state'); + const result = await facade.checkPermission({ + toolName: 'read', + agentName: 'fixture-agent', + input: { path: resource }, + }); + expect(result.behavior).toBe('ask'); + } + expect(classify).not.toHaveBeenCalled(); + } finally { + cleanup(dataDir); + } + }, + ); + + it.each(['.builtin-skills', 'agents/fixture-agent/skills'])( + 'requires approval when the %s root aliases private runtime state', + async (skillRoot) => { + const { facade, dataDir } = freshFacade({ dataDirParent: homedir() }); + try { + const privateRoot = path.join(dataDir, 'private-state'); + mkdirSync(privateRoot); + writeFileSync(path.join(privateRoot, 'notes.txt'), 'synthetic private state'); + const alias = path.join(dataDir, skillRoot); + mkdirSync(path.dirname(alias), { recursive: true }); + symlinkSync(privateRoot, alias, 'dir'); + for (const toolName of ['read', 'grep']) { + const result = await facade.checkPermission({ + toolName, + agentName: 'fixture-agent', + input: { + path: toolName === 'read' ? path.join(alias, 'notes.txt') : alias, + pattern: '*', + }, + }); + expect(result.behavior).toBe('ask'); + } + } finally { + cleanup(dataDir); + } + }, + ); + it.each(['read', 'grep', 'glob', 'list'])( 'keeps private runtime %s requests out of automatic approval', async (toolName) => { @@ -1757,10 +1862,14 @@ describe('LocalPermissionFacade', () => { writeFileSync(configPath, 'synthetic: fixture-only-secret\n'); mkdirSync(path.join(dataDir, 'memory')); mkdirSync(path.join(dataDir, 'skills')); + mkdirSync(path.join(dataDir, '.builtin-skills')); + mkdirSync(path.join(dataDir, 'agents', 'fixture-agent', 'skills'), { recursive: true }); const aliases = [ path.join(workspaceDir, 'notes.txt'), path.join(dataDir, 'memory', 'notes.txt'), path.join(dataDir, 'skills', 'notes.txt'), + path.join(dataDir, '.builtin-skills', 'notes.txt'), + path.join(dataDir, 'agents', 'fixture-agent', 'skills', 'notes.txt'), ]; for (const alias of aliases) { symlinkSync(configPath, alias); diff --git a/packages/local-runtime/test/unit/local-runtime-host-output-safety-concurrency.test.ts b/packages/local-runtime/test/unit/local-runtime-host-output-safety-concurrency.test.ts index b83a2e333..246ebcb58 100644 --- a/packages/local-runtime/test/unit/local-runtime-host-output-safety-concurrency.test.ts +++ b/packages/local-runtime/test/unit/local-runtime-host-output-safety-concurrency.test.ts @@ -1,3 +1,5 @@ +import { LocalAgentTurnRunner } from '../../../local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.js'; +import { logger } from '../../src/common/logger.js'; // Concurrency isolation for the output-safety host loop. // // The blocked flag / abort / review buffers live on per-attempt writer instances @@ -8,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LocalRuntimeHost, type LocalTurnRunner } from '../../src/runtime/host.js'; import { RespDataType } from '@mavis/agent-core/protocol/agent-message'; -import { RuntimeEventType, type IRuntimeEvent } from '@mavis/protocol'; +import { RuntimeEventStatus, RuntimeEventType, type IRuntimeEvent } from '@mavis/protocol'; function finalMessage(content: string): IRuntimeEvent { return { @@ -57,6 +59,91 @@ describe('LocalRuntimeHost output safety — concurrency isolation', () => { delete process.env.IDC; }); + it('records a review stop while retaining completed terminal semantics', async () => { + const log = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new DOMException('timeout', 'TimeoutError')); + const host = new LocalRuntimeHost({ + safetyApiVersion: 'v2', + outputSafetyRetryDelay: async () => {}, + piRunner: { + async runTurn(input) { + await input.eventWriter.pushRuntime(finalMessage('unreviewed-output')); + }, + }, + }); + const output = await host.runTurn({ + workspaceDir: '/tmp/ws', + systemPrompt: '', + userMessage: { text: 'hi' }, + llm: { model: {} as never }, + sessionId: 'ses', + turnId: 'turn', + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(output).toMatchObject({ networkStopped: true, retracted: false }); + expect(output.events.filter((event) => event.type === RuntimeEventType.STREAM_RESP)).toEqual( + [], + ); + expect(output.events).toContainEqual( + expect.objectContaining({ + type: RuntimeEventType.SESSION_STATUS, + payload: expect.objectContaining({ status: RuntimeEventStatus.COMPLETED }), + }), + ); + expect(log).toHaveBeenCalledWith( + { + sessionId: 'ses', + turnId: 'turn', + reviewOutcome: 'unavailable', + outputSuppressed: true, + terminalStatus: 'completed', + }, + '[content-safety] output review stopped turn', + ); + expect(JSON.stringify(log.mock.calls)).not.toContain('unreviewed-output'); + }); + + it.each([false, true])('diagnoses the V2 review stop without overriding runner failure: %s', async (runnerFailed) => { + const log = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const events: IRuntimeEvent[] = []; + const eventWriter = { + events, + async pushRuntime(event: IRuntimeEvent) { events.push(event); }, + async appendEvents(next: IRuntimeEvent[]) { events.push(...next); }, + }; + const reviewContent = vi.fn(async () => ({ pass: false, errorKind: 'local_error' as const })); + const runner = new LocalAgentTurnRunner({ + reviewContent, + outputSafetyRetryDelay: async () => {}, + piRunner: { async runTurn(input) { + await input.eventWriter.pushRuntime(finalMessage('unreviewed-output')); + if (runnerFailed) await input.eventWriter.pushRuntime({ + ...finalMessage(''), type: RuntimeEventType.SESSION_STATUS, + payload: { status: RuntimeEventStatus.FAILED }, + }); + } }, + }); + const output = await runner.runTurn({ + workspaceDir: '/tmp/ws', systemPrompt: '', userMessage: { text: 'hi' }, + llm: { model: {} as never }, sessionId: 'ses', turnId: 'turn', eventWriter, + toolContext: { agentName: 'test', parentAgentConfig: {}, trustedExactWritePaths: [], eventWriter } as never, + }); + expect(reviewContent).toHaveBeenCalledTimes(4); + expect(events.filter(event => event.type === RuntimeEventType.STREAM_RESP)).toEqual([]); + expect(output.outcome.status).toBe(runnerFailed ? 'failed' : 'completed'); + if (runnerFailed) { + expect(log).not.toHaveBeenCalled(); + } else { + expect(output).toMatchObject({ networkStopped: true, retracted: false, reconcile: { kind: 'network-reconcile' } }); + expect(log).toHaveBeenCalledWith({ + sessionId: 'ses', turnId: 'turn', reviewOutcome: 'unavailable', + outputSuppressed: true, terminalStatus: 'completed', + }, '[content-safety] output review stopped turn'); + } + }); + it('a rejected turn never contaminates a concurrent clean turn', async () => { const fetchMock = installContentAwareFetch(); // One shared runner + one shared host drive both turns. Each turn emits diff --git a/packages/local-runtime/test/unit/session-diff-fallback.test.ts b/packages/local-runtime/test/unit/session-diff-fallback.test.ts index 85c259df2..fe3842824 100644 --- a/packages/local-runtime/test/unit/session-diff-fallback.test.ts +++ b/packages/local-runtime/test/unit/session-diff-fallback.test.ts @@ -13,6 +13,7 @@ let root: string; let workspaceDir: string; let dataDir: string; let diffStore: SqliteLocalTurnDiffStore; +const pendingReads = new Set>(); function git(...args: string[]): string { return execFileSync('git', args, { cwd: workspaceDir, encoding: 'utf8' }); @@ -29,11 +30,17 @@ function commit(): void { } async function read(messageId?: string) { - return readLocalSessionDiff({ + const pending = readLocalSessionDiff({ diffStore, session: { sessionId: 'test-session', workspaceDir }, messageId, }); + pendingReads.add(pending); + try { + return await pending; + } finally { + pendingReads.delete(pending); + } } beforeEach(async () => { @@ -58,10 +65,14 @@ beforeEach(async () => { }); afterEach(async () => { + // A Vitest timeout does not cancel the Git processes started by a read. + // Drain them before changing their environment or deleting their cwd; otherwise + // Windows reports EBUSY and unfinished work can overlap the next fixture. + await Promise.allSettled(pendingReads); closeLocalRuntimeDb(dataDir); vi.unstubAllEnvs(); await rm(root, { recursive: true, force: true }); -}); +}, process.platform === 'win32' ? 30_000 : 10_000); const portableNames = ['中文.txt', 'with spaces.txt', 'dir/{before after}.txt']; const posixNames = [ @@ -77,6 +88,8 @@ describe.each(['true', 'false'])('session diff fallback with core.quotePath=%s', git('config', 'core.quotePath', quotePath); }); + // This integration case creates a commit, migrates SQLite and runs real Git. + // Real Windows runs have exhausted its default 5s test deadline. it('preserves Unicode paths and attaches each tracked patch through the public API', async () => { for (const file of portableNames) await write(file, 'first\n'); commit(); @@ -94,7 +107,7 @@ describe.each(['true', 'false'])('session diff fallback with core.quotePath=%s', diff: expect.stringContaining(`+added-${index}\n`), }); } - }); + }, process.platform === 'win32' ? 15_000 : 5_000); it('reports rename edits against the destination path', async () => { await write('before.txt', 'a\nb\nc\nd\ne\nf\n'); diff --git a/packages/shared/src/safety-check-v2.ts b/packages/shared/src/safety-check-v2.ts index d41f5bc13..40e42e381 100644 --- a/packages/shared/src/safety-check-v2.ts +++ b/packages/shared/src/safety-check-v2.ts @@ -36,11 +36,52 @@ export type SafetyCheckV2Headers = NonNullable; export type SafetyCheckV2ErrorKind = 'auth' | 'http' | 'transport' | 'response' | 'upstream'; +export type SafetyTransportKind = 'dns' | 'connect' | 'tls' | 'timeout' | 'aborted' | 'unknown'; + +/** Only allowlisted categories survive; never retain exception text, URLs or causes. */ +export function classifySafetyTransportError(error: unknown): SafetyTransportKind { + let current = error; + for (let depth = 0; depth < 4 && isSafetyCheckV2Record(current); depth += 1) { + const { name } = current; + const code = typeof current.code === 'string' ? current.code : ''; + if ( + name === 'TimeoutError' || + [ + 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT', + ].includes(code) + ) + return 'timeout'; + if (name === 'AbortError' || code === 'ABORT_ERR') return 'aborted'; + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'dns'; + if ( + ['ECONNREFUSED', 'ECONNRESET', 'ENETUNREACH', 'EHOSTUNREACH', 'UND_ERR_SOCKET'].includes(code) + ) + return 'connect'; + if ( + [ + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + ].includes(code) + ) + return 'tls'; + current = current.cause; + } + return 'unknown'; +} + /** A failed check has no verdict. Callers must handle it separately from Allow. */ export class SafetyCheckV2Error extends Error { constructor( readonly kind: SafetyCheckV2ErrorKind, readonly statusCode?: number, + readonly transportKind?: SafetyTransportKind, ) { super(`SafetyCheckV2 ${kind} error`); this.name = 'SafetyCheckV2Error'; @@ -71,8 +112,8 @@ export async function postSafetyCheckV2(input: { signal: input.signal ? AbortSignal.any([input.signal, timeout]) : timeout, redirect: 'error', }); - } catch { - throw new SafetyCheckV2Error('transport'); + } catch (error) { + throw new SafetyCheckV2Error('transport', undefined, classifySafetyTransportError(error)); } if (!response.ok) { throw new SafetyCheckV2Error( @@ -83,8 +124,13 @@ export async function postSafetyCheckV2(input: { let body: unknown; try { body = await response.json(); - } catch { - throw new SafetyCheckV2Error('response'); + } catch (error) { + const transportKind = classifySafetyTransportError(error); + throw new SafetyCheckV2Error( + transportKind === 'unknown' ? 'response' : 'transport', + response.status, + transportKind, + ); } if (!isSafetyCheckV2Record(body)) throw new SafetyCheckV2Error('response'); return body; diff --git a/packages/tui/docs/status-line-config.md b/packages/tui/docs/status-line-config.md index 9975de665..c1edaa8cf 100644 --- a/packages/tui/docs/status-line-config.md +++ b/packages/tui/docs/status-line-config.md @@ -60,6 +60,7 @@ Configuration loading checks that the value is an array. The TUI validates indiv | `token-quota` | Token quota / plan | | `cache-read-ratio` | Aggregate cache read ratio for the current session, such as `Cache 80%` | | `context-remaining` | Remaining context percentage from the same runtime snapshot as `/context`; hidden if unavailable | +| `context-meter` | Remaining context gauge, e.g. `Context ▕██████░░▏ 77% left`, from the same runtime snapshot as `/context`; hidden if unavailable; opt-in | | `custom-command` | External command stdout; first line inline by default, optional separate multiline block; opt-in | `cache-read-ratio` aggregates persisted session usage as `cacheRead / (input + cacheRead + cacheWrite)`, where `input` is fresh uncached input. It uses existing provider usage fields; missing cache counts contribute zero, so sessions mixing providers can underestimate cache reads. The item is hidden before any prompt tokens exist. In-process notifications refresh the session after usage persists; read failures stay silent. `/usage` also shows Read, Fresh, and Write token counts. @@ -78,6 +79,7 @@ Configuration loading checks that the value is an array. The TUI validates indiv | `quota` / `token-plan` | `token-quota` | | `cache-read` | `cache-read-ratio` | | `context` / `context-left` | `context-remaining` | +| `context-bar` / `context-gauge` | `context-meter` | | `custom` | `custom-command` | ## Defaults @@ -90,7 +92,7 @@ current-dir · session-title · git-branch · review-link · plan-mode · approv `build-mode` is **not a default**. Explicitly listing it is the only way to enable the machine-readable protocol; the build type does not matter. It may appear anywhere in the list and takes over the entire line to keep free text out of the strict record. Remove it to disable the protocol. -By default, `context-window` shows capacity before usage is available; `context-remaining` replaces it once a runtime usage snapshot exists. Explicit item lists retain the selected presentation. Session changes clear old data, and refresh failures do not reuse stale values. Context comes from the latest runtime snapshot, not a live per-token counter. `cache-read-ratio` remains opt-in. Narrow terminals prioritize permissions, context warnings, model, and session title. Even the shortest context display retains `Ctx` to distinguish it from `5h` / `W` plan quotas. +By default, `context-window` shows capacity before usage is available; `context-remaining` replaces it once a runtime usage snapshot exists. Explicit item lists retain the selected presentation. Session changes clear old data, and refresh failures do not reuse stale values. Context comes from the latest runtime snapshot, not a live per-token counter. `cache-read-ratio` and `context-meter` remain opt-in; `context-meter` renders the same remaining-headroom value and thresholds as `context-remaining` with an added gauge, and takes precedence over `context-remaining` when both are listed, regardless of order. Other items retain their order, and the meter stays at its configured position. It shrinks from eight gauge cells to six, then to `Ctx N%` as space runs out. Narrow terminals prioritize permissions, context warnings, model, and session title. Even the shortest context display retains `Ctx` to distinguish it from `5h` / `W` plan quotas. `custom-command` is also **not a default**. It runs an external command and requires both an explicit item and `tui.customStatusLine.command`. diff --git a/packages/tui/src/cli/contract.ts b/packages/tui/src/cli/contract.ts index 66aab2502..8b863b7f1 100644 --- a/packages/tui/src/cli/contract.ts +++ b/packages/tui/src/cli/contract.ts @@ -1,4 +1,5 @@ import { InvalidArgumentError, Option, type Command } from 'commander'; +import { parseHeadlessModelOverride } from '../headless/model-selection.js'; import type { TuiMode } from '../tui/engine/public.js'; import { parseTuiStartupEnvironment } from './environment.js'; import type { TuiBuildEnvironment } from '../auth/environment.js'; @@ -7,6 +8,7 @@ const RETIRED_TOP_LEVEL_COMMAND_NAMES = new Set(['git', 'changes', 'projects']); export interface TuiInteractiveLaunchRequest { readonly initialPrompt?: string; + readonly model?: string; readonly sessionId?: string; readonly showSessionPicker?: boolean; readonly continueLatestSession?: boolean; @@ -17,6 +19,7 @@ export interface TuiInteractiveLaunchRequest { } export interface RawTuiInteractiveOptions { + readonly model?: string; readonly session?: string | boolean; readonly continue?: boolean; readonly resume?: string; @@ -31,6 +34,7 @@ export function applyInteractiveCliContract( ): Command { const configured = command .argument('[prompt]', 'task to execute in the interactive TUI') + .addOption(new Option('-m, --model ', 'select the model for this Session only')) .addOption(new Option('--lane ', 'managed backend lane for test or staging builds')) .addOption( new Option('--session [id]', 'open a Session by id, or browse Sessions when id is omitted'), @@ -75,8 +79,18 @@ export function resolveInteractiveLaunchRequest( throw new Error('--session, --continue, and --resume cannot be combined'); } const sessionId = explicitSessionId || compatibilitySessionId; + const model = commandOptions.model?.trim(); + if (commandOptions.model !== undefined) { + parseHeadlessModelOverride(model ?? ''); + if (showSessionPicker) { + throw new Error( + '--model requires a Session id with --session; use --session or --continue.', + ); + } + } return { ...(prompt ? { initialPrompt: prompt } : {}), + ...(model ? { model } : {}), ...(sessionId ? { sessionId } : {}), ...(showSessionPicker ? { showSessionPicker: true } : {}), ...(commandOptions.continue ? { continueLatestSession: true } : {}), diff --git a/packages/tui/src/cli/environment.ts b/packages/tui/src/cli/environment.ts index c48171534..4bbc0d44d 100644 --- a/packages/tui/src/cli/environment.ts +++ b/packages/tui/src/cli/environment.ts @@ -17,7 +17,13 @@ import { readTuiRegionPreference } from '../auth/region-preference.js'; const MANAGED_BACKEND_LANE_PATTERN = /^[A-Za-z0-9._-]+$/u; const STARTUP_ENVIRONMENT_OPTION = '--env'; -const ROOT_OPTIONS_WITH_REQUIRED_VALUES = new Set(['--lane', '--resume', '--tui-mode']); +const ROOT_OPTIONS_WITH_REQUIRED_VALUES = new Set([ + '--lane', + '--resume', + '--tui-mode', + '-m', + '--model', +]); const STARTUP_ENVIRONMENT_ALIASES: Readonly> = Object.freeze({ test: 'test', pre: 'staging', diff --git a/packages/tui/src/cli/main.ts b/packages/tui/src/cli/main.ts index 17bbf44af..8cff990e1 100644 --- a/packages/tui/src/cli/main.ts +++ b/packages/tui/src/cli/main.ts @@ -97,6 +97,7 @@ export async function runTuiCli(dependencies: RunTuiCliDependencies = {}): Promi allowStartupEnvironmentSelection: dependencies.allowStartupEnvironmentSelection, launchTui: async ({ initialPrompt, + model, sessionId, showSessionPicker, continueLatestSession, @@ -108,6 +109,7 @@ export async function runTuiCli(dependencies: RunTuiCliDependencies = {}): Promi await launchTui({ version: MINIMAX_CODE_VERSION, ...(initialPrompt ? { initialPrompt } : {}), + ...(model ? { model } : {}), ...(sessionId ? { sessionId } : {}), ...(showSessionPicker ? { showSessionPicker: true } : {}), ...(continueLatestSession ? { continueLatestSession: true } : {}), diff --git a/packages/tui/src/cli/program.ts b/packages/tui/src/cli/program.ts index 6355ba65c..a28b361bc 100644 --- a/packages/tui/src/cli/program.ts +++ b/packages/tui/src/cli/program.ts @@ -176,6 +176,7 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command { .option('--api-key-env ', 'environment variable containing the API key') .option('--context-limit ', 'context limit for every listed model', parsePositiveSafeInteger) .option('--output-limit ', 'output limit for every listed model', parsePositiveSafeInteger) + .option('--support-image', 'declare image input support for every listed model') .option('--use', 'test the first model, then save and select it as the default') .action( (commandOptions: { @@ -185,6 +186,7 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command { model: string[]; contextLimit?: number; outputLimit?: number; + supportImage?: boolean; apiKeyEnv?: string; use?: boolean; }) => { @@ -199,6 +201,7 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command { models: commandOptions.model, contextLimit: commandOptions.contextLimit, outputLimit: commandOptions.outputLimit, + supportImage: commandOptions.supportImage, apiKeyEnv: commandOptions.apiKeyEnv, saveAndUse: commandOptions.use, }); diff --git a/packages/tui/src/cli/provider-command.ts b/packages/tui/src/cli/provider-command.ts index a3ce4623f..01798da7b 100644 --- a/packages/tui/src/cli/provider-command.ts +++ b/packages/tui/src/cli/provider-command.ts @@ -14,6 +14,7 @@ export type McodeProviderCliRequest = readonly models: readonly string[]; readonly contextLimit?: number; readonly outputLimit?: number; + readonly supportImage?: boolean; readonly apiKeyEnv?: string; readonly saveAndUse?: boolean; } @@ -67,6 +68,7 @@ export async function runMcodeProviderCommand( apiFormat: request.apiFormat, models: request.models.map((modelId) => ({ modelId, + ...(request.supportImage ? { capabilities: { support_image: true } } : {}), ...(request.contextLimit !== undefined || request.outputLimit !== undefined ? { limit: { diff --git a/packages/tui/src/provider/contract.ts b/packages/tui/src/provider/contract.ts index b59d5f81d..355a38bfa 100644 --- a/packages/tui/src/provider/contract.ts +++ b/packages/tui/src/provider/contract.ts @@ -1,3 +1,5 @@ +import type { ModelConfig } from '@mavis/config'; + export const MCODE_PROVIDER_API_FORMATS = [ 'anthropic-messages', 'openai-completions', @@ -96,6 +98,7 @@ export interface McodeProviderModelInput { readonly reasoning?: boolean; readonly toolCall?: boolean; readonly temperature?: boolean; + readonly capabilities?: Readonly>; readonly modalities?: { readonly input?: readonly string[]; readonly output?: readonly string[] }; readonly limit?: { readonly context?: number; readonly output?: number }; } diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 7a430e258..4bdb3af98 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -1218,7 +1218,7 @@ export class TuiCommandFlow { this.options.append( formatTuiActionFailure(error, { summary: "Couldn't reload TUI configuration.", - nextStep: 'Fix keybindings.json and retry /reload.', + nextStep: 'Resolve the reported configuration or plugin refresh error, then retry /reload.', }), 'warning', ); diff --git a/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts b/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts index 8ffd3bbac..5ce68b04b 100644 --- a/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts +++ b/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts @@ -458,7 +458,7 @@ export class TuiRuntimeEventFlow { event.variant === 'auth' ? 'Content review authentication expired. Run /login, then retry.' : event.variant === 'network' - ? 'The content review service could not be reached, so this response stopped. Check your network and proxy or VPN settings, then retry.' + ? 'Content review did not return a usable result, so this response stopped. Unreviewed output was withheld. Retry; if this persists, use /feedback to report the review failure.' : 'Content review withdrew this response. Rephrase your request, then retry.', 'warning', ); diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 8d88e13ec..3fb374d81 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -47,6 +47,13 @@ "reason": "Keep strict TypeScript fixes and product Markdown hooks, plus Pi's post-baseline wrapped-table style restoration. 链接标签使用独立样式上下文,防止外层正文或标题颜色覆盖链接颜色。 稳定 Markdown 块缓存", "behaviorImpact": "MCode keeps its compact code presentation without reimplementing Pi Markdown or leaking wrapped link styles into table borders and adjacent cells. 链接保留嵌套强调与行内代码,并在结束后恢复外层样式。 保留两个尾部块并对引用回退全文。" }, + { + "path": "components/scroll-view.ts", + "currentSha256": "6f82fb1f426fdf81ca7058e6a1ea4a94c9ddd8f0e15d69c9020f9c66ca5411dd", + "changeIds": ["L035"], + "reason": "Allow hosts to reserve a wider always-visible scrollbar gutter while retaining at least one content column.", + "behaviorImpact": "The fullscreen transcript reserves three columns; other scroll views retain the default one-column gutter." + }, { "path": "components/settings-list.ts", "currentSha256": "959f7db23190e0e526ca84c3032443d7d15248d891809f28c837cfa958692b5c", @@ -148,11 +155,11 @@ }, { "path": "tui-alt-screen.ts", - "currentSha256": "dd8acaa441bab2e674e6cba946dcb130b97e51098adf158bda1f73bd027d7a4b", - "changeIds": ["L005", "L011", "L019", "L020", "L025", "L031"], + "currentSha256": "b1e6b332e976c5224f77ce82ee850d35c601ad18c3719d9c824b7c9db1245a78", + "changeIds": ["L005", "L011", "L019", "L020", "L025", "L031", "L035"], "upstreamCommit": "4e494929998d6bc4fccf75e0a233f727db4b70ee,1ac6128e66bd44668b58db773c2a76c29163ade5", - "reason": "Keep strict TypeScript fixes and layout-first mouse dispatch, join path and kebab-case word segments, and expose configurable fullscreen selection copying helpers. 活动搜索索引与命中复用 独立面板声明键盘分页归属。", - "behaviorImpact": "Fullscreen product interaction clicks and Pi-owned text selection coexist; double-click selects complete path-like tokens, and hosts can disable automatic copy or copy an active selection explicitly. 搜索交互复用索引。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。" + "reason": "Keep strict TypeScript fixes and layout-first mouse dispatch, join path and kebab-case word segments, and expose configurable fullscreen selection copying helpers. 活动搜索索引与命中复用 独立面板声明键盘分页归属。 Reserve scrollbar hit targets for gutter columns and support track jumps and thumb dragging.", + "behaviorImpact": "Fullscreen product interaction clicks and Pi-owned text selection coexist; double-click selects complete path-like tokens, and hosts can disable automatic copy or copy an active selection explicitly. 搜索交互复用索引。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。 Fullscreen track presses jump and drag without consuming adjacent content; visible overlays cancel active scrollbar drags." }, { "path": "tui-main-screen.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index a0ec30447..e2f84903b 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -106,3 +106,12 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Change: strip leading zone prefixes before comparing screen lines, so initial frames, differential redraws, and history caches stay clean. Fullscreen behavior is unchanged. - Evidence: `test/unit/tui-engine-local-deltas.test.ts` checks initial and differential writes. - Removal condition: the selected Pi baseline supplies equivalent normal-mode filtering. + +## L035: Fullscreen scrollbar track interaction + +- Origin: adapted from [drowzeys/minimax-code commit 0b24a7778741fda335a9a2074464c69911feb1aa](https://github.com/drowzeys/minimax-code/commit/0b24a7778741fda335a9a2074464c69911feb1aa), contributed by drowzeys under the existing MIT license in [issue #216](https://github.com/MiniMax-AI/minimax-code/issues/216). +- Product contract: the fullscreen transcript enables an always-visible scrollbar with a three-column hit gutter. Track presses center the thumb and jump before dragging; thumb presses retain their grab offset without an initial jump. +- Minimal difference: `components/scroll-view.ts` accepts an optional `scrollbarGutter` for always-visible bars, defaulting to one column and retaining at least one content column on narrow terminals. `tui-alt-screen.ts` restricts wide hit targets to that reserved gutter, factors drag mapping into `dragScrollbarTo`, and cancels a drag when an overlay receives the next mouse event. Auto bars retain a one-column target. `layout.ts` stays at the Pi baseline. +- Adaptation: the original contribution widened hits over two live content columns. The reserved gutter prevents those presses from swallowing content clicks or text selection. `chat-layout.ts` opts into the three-column gutter. +- Evidence: `test/unit/tui-scrollbar-interaction.test.ts` drives SGR press, motion, release and wheel events through VirtualTerminal, covering track jumps, thumb grabs, narrow terminals, content routing, selection, overlays and the actual fullscreen ChatLayout. +- Removal condition: the selected Pi baseline provides equivalent reserved-gutter track and drag interaction and MCode migrates to it. diff --git a/packages/tui/src/tui/engine/components/scroll-view.ts b/packages/tui/src/tui/engine/components/scroll-view.ts index b10421282..41f8444ad 100644 --- a/packages/tui/src/tui/engine/components/scroll-view.ts +++ b/packages/tui/src/tui/engine/components/scroll-view.ts @@ -9,6 +9,8 @@ export interface ScrollViewOptions { primary?: boolean; overscroll?: "chain" | "contain"; scrollbar?: ScrollViewScrollbar; + /** Columns reserved for an always-visible scrollbar, including its hit gutter. */ + scrollbarGutter?: number; scrollbarStyle?: (text: string) => string; scrollbarHideDelayMs?: number; } @@ -24,6 +26,7 @@ export class ScrollView extends Container { readonly primary: boolean; readonly overscroll: "chain" | "contain"; readonly scrollbarStyle: (text: string) => string; + private readonly scrollbarGutter: number; private currentScrollbar: ScrollViewScrollbar; private readonly scrollbarHideDelayMs: number; private currentScrollTop = 0; @@ -48,6 +51,9 @@ export class ScrollView extends Container { this.primary = options.primary ?? false; this.overscroll = options.overscroll ?? "chain"; this.currentScrollbar = options.scrollbar ?? "hidden"; + this.scrollbarGutter = Number.isFinite(options.scrollbarGutter) + ? Math.max(1, Math.floor(options.scrollbarGutter ?? 1)) + : 1; this.scrollbarStyle = options.scrollbarStyle ?? ((text) => `\x1b[100m${text}\x1b[49m`); this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000)); } @@ -84,7 +90,7 @@ export class ScrollView extends Container { } getContentWidth(width: number): number { - return this.scrollbar === "always" && width > 1 ? width - 1 : width; + return this.scrollbar === "always" && width > 1 ? Math.max(1, width - this.scrollbarGutter) : width; } private markScrollbarActivity(): void { @@ -207,7 +213,7 @@ export class ScrollView extends Container { override render(width: number): string[] { const contentWidth = this.getContentWidth(width); const lines = this.child.render(contentWidth); - return contentWidth === width ? lines : lines.map((line) => `${line} `); + return contentWidth === width ? lines : lines.map((line) => `${line}${" ".repeat(width - contentWidth)}`); } [LAYOUT_NODE](): ScrollLayoutNode { diff --git a/packages/tui/src/tui/engine/tui-alt-screen.ts b/packages/tui/src/tui/engine/tui-alt-screen.ts index e92b1b093..4fa374a5a 100644 --- a/packages/tui/src/tui/engine/tui-alt-screen.ts +++ b/packages/tui/src/tui/engine/tui-alt-screen.ts @@ -776,11 +776,18 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) { const box = getScrollViewBox(this.currentLayout, scrollView); const geometry = box ? getScrollbarGeometry(box) : undefined; + // Only reserved gutter columns may capture presses beside the painted bar. + const hitColumns = box + ? scrollView.scrollbar === "always" + ? box.rect.width - scrollView.getContentWidth(box.rect.width) + : 1 + : 0; if ( geometry && - x === geometry.column && - y >= geometry.thumbTop && - y < geometry.thumbTop + geometry.thumbHeight + x >= geometry.column - (hitColumns - 1) && + x <= geometry.column && + y >= geometry.trackTop && + y < geometry.trackTop + geometry.trackHeight ) { return { scrollView, geometry }; } @@ -804,25 +811,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private handleScrollbarMouseEvent(event: SgrMouseEvent): boolean { + if (this.hasOverlay()) { + this.stopScrollbarDrag(); + return false; + } if (this.scrollbarDrag) { if (event.release) { this.stopScrollbarDrag(); return true; } - const box = this.currentLayout - ? getScrollViewBox(this.currentLayout, this.scrollbarDrag.scrollView) - : undefined; - const geometry = box ? getScrollbarGeometry(box) : undefined; - if (geometry) { - const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight; - const thumbOffset = Math.max( - 0, - Math.min(maxThumbOffset, event.y - geometry.trackTop - this.scrollbarDrag.grabOffset), - ); - const scrollTop = - maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop); - this.scrollbarDrag.scrollView.scrollTo(scrollTop); - } + this.dragScrollbarTo(event.y); return true; } @@ -839,13 +837,39 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.pressedUrl = undefined; this.selectionDragged = false; this.setScrollbarHover(target.scrollView); + const onThumb = + event.y >= target.geometry.thumbTop && + event.y < target.geometry.thumbTop + target.geometry.thumbHeight; this.scrollbarDrag = { scrollView: target.scrollView, - grabOffset: event.y - target.geometry.thumbTop, + grabOffset: onThumb + ? event.y - target.geometry.thumbTop + : Math.max(0, Math.floor(target.geometry.thumbHeight / 2)), }; + // A press on the empty track jumps the thumb so its center follows the + // pointer, then keeps dragging from there; a press on the thumb itself + // grabs it in place without moving. + if (!onThumb) this.dragScrollbarTo(event.y); return true; } + private dragScrollbarTo(y: number): void { + if (!this.scrollbarDrag || !this.currentLayout) return; + const box = getScrollViewBox(this.currentLayout, this.scrollbarDrag.scrollView); + const geometry = box ? getScrollbarGeometry(box) : undefined; + if (!geometry) return; + const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight; + const thumbOffset = Math.max( + 0, + Math.min(maxThumbOffset, y - geometry.trackTop - this.scrollbarDrag.grabOffset), + ); + const scrollTop = + maxThumbOffset === 0 + ? 0 + : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop); + this.scrollbarDrag.scrollView.scrollTo(scrollTop); + } + private stopScrollbarDrag(): void { this.scrollbarDrag = undefined; } diff --git a/packages/tui/src/tui/features/settings/status-line-copy.ts b/packages/tui/src/tui/features/settings/status-line-copy.ts index b6ed99f33..9ee787c42 100644 --- a/packages/tui/src/tui/features/settings/status-line-copy.ts +++ b/packages/tui/src/tui/features/settings/status-line-copy.ts @@ -52,6 +52,7 @@ const descriptions: Record, readonly [s 'token-quota': ['Account token quota', '账户额度'], 'cache-read-ratio': ['Session cache read ratio', '会话缓存读取比例'], 'context-remaining': ['Remaining context window', '剩余上下文窗口'], + 'context-meter': ['Remaining context gauge', '剩余上下文刻度条'], 'custom-command': ['Configured custom command output', '已配置的自定义命令输出'], }; diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index be011e105..b56ec2357 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -21,7 +21,8 @@ import { } from '../host/tui-keybindings.js'; import { createTuiApp, type CreateTuiAppOptions, type TuiApp } from './app.js'; import type { CreatedTuiRuntime, CreateTuiRuntimeDependencies } from '../runtime/lifecycle.js'; -import type { TuiWorkspaceRoot } from '../runtime/port.js'; +import { parseHeadlessModelOverride } from '../headless/model-selection.js'; +import type { TuiRuntime, TuiWorkspaceRoot } from '../runtime/port.js'; import { createDeferredTuiRuntime } from '../runtime/deferred.js'; import { captureTuiIncidentBestEffort, @@ -59,6 +60,7 @@ const MINIMAX_CODE_EXIT_SLOGAN = 'Intelligence with everyone, bye~'; export interface LaunchTuiOptions { version: string; initialPrompt?: string; + model?: string; sessionId?: string; showSessionPicker?: boolean; continueLatestSession?: boolean; @@ -76,7 +78,8 @@ export interface LaunchTuiOptions { type LaunchApp = Pick & { readonly editor?: Pick; readonly setStartupStatus?: TuiApp['setStartupStatus']; - readonly controller?: Pick; + readonly controller?: Pick & + Partial>; readonly openSession?: TuiApp['openSession']; readonly continueLatestSession?: TuiApp['continueLatestSession']; readonly suspend?: TuiApp['suspend']; @@ -127,6 +130,12 @@ export async function launchTui( options: LaunchTuiOptions, dependencies: LaunchTuiDependencies = {}, ): Promise { + if (options.model !== undefined) parseHeadlessModelOverride(options.model.trim()); + if (options.model !== undefined && options.showSessionPicker) { + throw new Error( + '--model requires a Session id with --session; use --session or --continue.', + ); + } if (!options.terminal && (!process.stdin.isTTY || !process.stdout.isTTY)) { throw new Error('Minimax Code interactive mode requires a TTY.'); } @@ -444,7 +453,7 @@ export async function launchTui( durationMs: performance.now() - runtimeStartedAt, }); await app.ready; - initialStateReady = await prepareInitialTuiState(app, observability, options); + initialStateReady = await prepareInitialTuiState(app, observability, options, runtime.adapter); startupStatus.stop(); startupStatus = undefined; if (app.editor) app.editor.disableSubmit = false; @@ -806,6 +815,7 @@ async function prepareInitialTuiState( app: LaunchApp, observability: TuiObservability, options: LaunchTuiOptions, + runtime: TuiRuntime, ): Promise { const sessionId = options.sessionId?.trim(); if (sessionId) { @@ -834,6 +844,20 @@ async function prepareInitialTuiState( return false; } } + if (options.model !== undefined) { + const model = parseHeadlessModelOverride(options.model.trim()); + if (!app.controller?.ensureSession || !app.openSession) { + throw new Error('TUI Session model selection is unavailable.'); + } + const session = await app.controller.ensureSession(); + // The normal model picker also saves the global default. Startup overrides + // must use the Session-only operation, before login checks or any submission. + if (!(await runtime.selectSessionModel(model, session.sessionId))) { + throw new Error(`Could not select --model ${options.model}. Check the provider and model id.`); + } + // Rehydrate account status, model/effort and queued-input state from Runtime. + await app.openSession(session.sessionId); + } if (options.showSessionPicker) await app.submit('/sessions'); return true; } diff --git a/packages/tui/src/tui/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index 01abf46c0..e3e2a62e9 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -124,6 +124,8 @@ export class TuiChatLayout implements Component { follow: 'end', primary: true, overscroll: 'contain', + scrollbar: 'always', + scrollbarGutter: 3, }); this.fullscreenLayoutRoot = new VStack([ { diff --git a/packages/tui/src/tui/shell/chrome.ts b/packages/tui/src/tui/shell/chrome.ts index b65c55254..117435b61 100644 --- a/packages/tui/src/tui/shell/chrome.ts +++ b/packages/tui/src/tui/shell/chrome.ts @@ -134,7 +134,12 @@ export class TuiStatusLine implements Component { function resolveStatusLineItems(state: TuiShellState): readonly TuiStatusLineItem[] { const items = state.statusLineItems ?? TUI_STATUS_LINE_DEFAULT_ITEMS; if (!items.includes('build-mode')) { - if (state.statusLineItems !== undefined) return items; + if (state.statusLineItems !== undefined) { + // The opt-in meter replaces the percentage at the meter's configured position. + return items.includes('context-meter') + ? items.filter((item) => item !== 'context-remaining') + : items; + } const remaining = contextRemainingPercent(state); return items.filter((item) => remaining === undefined ? item !== 'context-remaining' : item !== 'context-window', @@ -249,11 +254,12 @@ function buildStatusSegment( { shrinkPriority: 48, dropPriority: 10 }, ); case 'context-remaining': + case 'context-meter': return createStatusSegment( [ - renderContextRemaining(state, 'full'), - renderContextRemaining(state, 'compact'), - renderContextRemaining(state, 'minimal'), + renderContextRemaining(state, 'full', item === 'context-meter'), + renderContextRemaining(state, 'compact', item === 'context-meter'), + renderContextRemaining(state, 'minimal', item === 'context-meter'), ], { shrinkPriority: 50, dropPriority: 80 }, ); @@ -443,19 +449,25 @@ function contextRemainingPercent(state: TuiShellState): number | undefined { return Math.round(((contextWindow - used) / contextWindow) * 100); } +/** Both presentations share usage, fallback, thresholds and the minimal label. */ function renderContextRemaining( state: TuiShellState, density: 'full' | 'compact' | 'minimal' = 'full', + meter = false, ): string { const remaining = contextRemainingPercent(state); if (remaining === undefined) return ''; const color = remaining <= 10 ? colors.error : remaining <= 25 ? colors.warning : colors.muted; + let gauge = ''; + if (meter && density !== 'minimal') { + const cells = density === 'full' ? 8 : 6; + const filled = Math.round((remaining / 100) * cells); + gauge = `▕${'█'.repeat(filled)}${'░'.repeat(cells - filled)}▏ `; + } const label = density === 'full' - ? `Context ${remaining}% left` - : density === 'compact' - ? `Ctx ${remaining}%` - : `Ctx ${remaining}%`; + ? `Context ${gauge}${remaining}% left` + : `Ctx ${gauge}${remaining}%`; return chalk.hex(color)(label); } diff --git a/packages/tui/src/tui/shell/status-line-items.ts b/packages/tui/src/tui/shell/status-line-items.ts index 98ed52959..c92149c0f 100644 --- a/packages/tui/src/tui/shell/status-line-items.ts +++ b/packages/tui/src/tui/shell/status-line-items.ts @@ -9,7 +9,7 @@ * (`tui.statusLine`), so renaming one is a breaking change. Legacy aliases keep * older configuration working. * - * `cache-read-ratio`, `context-remaining` and `review-link` are conditional + * `cache-read-ratio`, `context-remaining`, `context-meter` and `review-link` are conditional * items. They only render when the Runtime surfaces their matching projection * into the shell state, so unsupported or not-yet-recorded metrics stay silent. * @@ -34,6 +34,7 @@ export const TUI_STATUS_LINE_ITEMS = [ 'token-quota', 'cache-read-ratio', 'context-remaining', + 'context-meter', 'custom-command', ] as const; @@ -67,6 +68,8 @@ const TUI_STATUS_LINE_ITEM_ALIASES: Readonly> 'cache-read': 'cache-read-ratio', context: 'context-remaining', 'context-left': 'context-remaining', + 'context-bar': 'context-meter', + 'context-gauge': 'context-meter', custom: 'custom-command', }; diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 63b97f170..0ecd36d61 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -8,6 +8,10 @@ import { VStack, type Terminal, } from "../../src/tui/engine/public.js"; +import { createTuiProgram } from "../../src/cli/program.js"; +import { resolveTuiStartupEnvironmentOption } from "../../src/cli/environment.js"; +import { runTuiCli } from "../../src/cli/main.js"; +import { launchTui } from "../../src/tui/launcher.js"; import { createTuiApp } from "../../src/tui/app.js"; import type { TuiModel, @@ -10562,7 +10566,10 @@ describe("createTuiApp", () => { await app.stop(); }); - it("restores a content-review retraction to the Composer without a failed Queue item", async () => { + it.each([ + ['content', 'Content review withdrew this response. Rephrase your request, then retry.'], + ['network', 'Content review did not return a usable result, so this response stopped.'], + ] as const)("presents a %s review stop without a failed Queue item", async (variant, notice) => { const terminal = new FakeTerminal(); const runtime = createRuntime(); const busEvents: TuiRuntimeEvent[] = []; @@ -10610,7 +10617,7 @@ describe("createTuiApp", () => { type: "content.retry.exceeded", timestamp: 100, source: "runtime-v2", - payload: { sessionId: "session-1", variant: "content" }, + payload: { sessionId: "session-1", variant }, }), runtimeEvent({ type: "message.rewind", @@ -10626,9 +10633,8 @@ describe("createTuiApp", () => { expect(app.editor.getText()).toBe("review this response"), ); const rendered = app.tui.render(100).join("\n"); - expect(rendered).toContain( - "Content review withdrew this response. Rephrase your request, then retry.", - ); + expect(rendered).toContain(notice); + expect(rendered).not.toContain('Check your network'); expect(rendered).not.toContain("Message kept under Failed"); await app.submit("/queue"); expect(app.interaction.current()).toBeUndefined(); @@ -13129,3 +13135,239 @@ describe("createTuiApp", () => { } }); }); + +describe("interactive CLI model startup", () => { + it.each([ + [ + "new with prompt", + ["-m", "custom_provider:relay/vendor/model#thinking", "hello"], + "session-1", + true, + ], + [ + "new without prompt", + ["--model=custom_provider:relay/vendor/model#thinking"], + "session-1", + false, + ], + [ + "explicit session", + ["--session", "existing", "--model", "custom_provider:relay/vendor/model#thinking", "hello"], + "existing", + true, + ], + [ + "continue", + ["--continue", "--model", "custom_provider:relay/vendor/model#thinking", "hello"], + "existing", + true, + ], + [ + "missing session", + ["--session", "missing", "-m", "custom_provider:relay/vendor/model#thinking", "hello"], + "missing", + false, + ], + [ + "empty continue", + ["--continue", "-m", "custom_provider:relay/vendor/model#thinking", "hello"], + "existing", + false, + ], + ["invalid provider", ["--model", "missing/model", "hello"], "session-1", false], + ["invalid model", ["--model", "custom_provider:relay/missing", "hello"], "session-1", false], + ] as const)( + "routes %s through launch, hydration and submission", + async (_name, args, sessionId, hasPrompt) => { + const dataDir = await mkdtemp(join(tmpdir(), "mcode-model-startup-")); + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + runtime.listBackgroundTasks = vi.fn(async () => []); + const models = new Map(); + const requested = { + providerId: "custom_provider:relay", + modelId: "vendor/model", + variant: "thinking", + }; + const existing = { sessionId: "existing", workspaceDir: "/workspace", updatedAt: 10 }; + vi.mocked(runtime.listSessions).mockResolvedValue([existing]); + vi.mocked(runtime.listSessionPage).mockResolvedValue({ + sessions: [existing], + hasMore: false, + }); + vi.mocked(runtime.getSession).mockImplementation(async (id) => ({ + sessionId: id, + workspaceDir: "/workspace", + model: models.get(id), + })); + const failedResume = _name === "missing session" || _name === "empty continue"; + if (_name === "missing session") + vi.mocked(runtime.getSession).mockRejectedValue(new Error("Session not found")); + if (_name === "empty continue") vi.mocked(runtime.listSessions).mockResolvedValue([]); + runtime.selectModel = vi.fn(async () => true); + runtime.selectSessionModel = vi.fn(async (model, id) => { + if (model.providerId !== requested.providerId || model.modelId !== requested.modelId) { + throw new Error("Unknown provider or model"); + } + models.set(id, model); + return true; + }); + vi.mocked(runtime.listModels).mockImplementation(async (id) => [ + { ...requested, name: "Startup model", selected: Boolean(id && models.has(id)) }, + ]); + // A global managed default needs login; the selected BYOK Session does not. + vi.mocked(runtime.getAccountStatus).mockImplementation(async (id) => ({ + status: id && models.has(id) ? "ready" : "needs-login", + modelSource: id && models.has(id) ? "byok" : "token-plan", + managedTokenPresent: false, + warnings: [], + })); + const sentModels: unknown[] = []; + vi.mocked(runtime.sendMessage).mockImplementation(async function* (request) { + sentModels.push(models.get(request.id)); + yield { type: "delta", content: "offline reply" }; + yield { type: "done" }; + }); + const invalid = _name.startsWith("invalid"); + const stderr = vi.fn((_text: string, callback?: () => void) => callback?.()); + const processRef = { + title: "test", + argv: ["node", "cli.js", ...args], + env: {}, + versions: { node: "24.2.0" }, + stdout: { + destroyed: false, + writableEnded: false, + write: vi.fn((_text: string, cb?: () => void) => cb?.()), + }, + stderr: { destroyed: false, writableEnded: false, write: stderr }, + exit: vi.fn(), + exitCode: 0, + }; + let app: ReturnType | undefined; + const running = runTuiCli({ + processRef, + configureNetworkProxy: () => undefined, + launchTui: (input) => + launchTui( + { ...input, dataDir, terminal, workspaceDir: "/workspace" }, + { + createObservability, + readTelemetryEnabled: () => false, + installProcessGuards: () => () => undefined, + loadRuntimeLifecycle: async () => ({ + createTuiRuntime: async () => ({ adapter: runtime }) as never, + shutdownTuiRuntime: async () => false, + }), + loadUpdateApplication: async () => + ({ inspect: async () => ({ status: "up-to-date" }) }) as never, + createApp: (options) => { + app = createTuiApp({ ...options, productFeatures: { queue: false } }); + return app; + }, + writeExitMessage: () => undefined, + }, + ), + }); + try { + if (invalid) { + await running; + expect(processRef.exitCode).toBe(1); + expect(stderr.mock.calls.flat().join(" ")).toContain("Unknown provider or model"); + expect(runtime.sendMessage).not.toHaveBeenCalled(); + } else if (failedResume) { + await vi.waitFor(() => expect(app?.editor.disableSubmit).toBe(false)); + expect(runtime.selectSessionModel).not.toHaveBeenCalled(); + expect(runtime.createSession).not.toHaveBeenCalled(); + expect(runtime.sendMessage).not.toHaveBeenCalled(); + } else { + await vi.waitFor(() => { + expect(app?.editor.disableSubmit).toBe(false); + expect(app?.controller.snapshot().session?.model).toEqual(requested); + if (hasPrompt) expect(sentModels).toEqual([requested]); + }); + expect(runtime.selectSessionModel).toHaveBeenCalledWith(requested, sessionId); + expect(app?.controller.snapshot().account?.modelSource).toBe("byok"); + if (sessionId === "existing") expect(runtime.createSession).not.toHaveBeenCalled(); + if (!hasPrompt) { + expect(runtime.sendMessage).not.toHaveBeenCalled(); + await app?.submit("later prompt"); + expect(sentModels).toEqual([requested]); + } + await vi.waitFor(() => expect(app?.controller.snapshot().status).toBe("idle")); + await app?.submit("/new"); + expect(app?.controller.snapshot().session).toBeUndefined(); + expect(runtime.selectSessionModel).toHaveBeenCalledOnce(); + } + expect(runtime.selectModel).not.toHaveBeenCalled(); + } finally { + await app?.stop(); + await running; + await rm(dataDir, { recursive: true, force: true }); + } + }, + ); +}); + +describe("interactive model argument contract", () => { + function program() { + const launch = vi.fn(async () => undefined); + const command = createTuiProgram({ + version: "test", + launchTui: launch, + runExec: vi.fn(), + runLogin: vi.fn(), + runLogout: vi.fn(), + runUpdate: vi.fn(), + }) + .exitOverride() + .configureOutput({ writeErr: () => undefined }); + return { command, launch }; + } + + it.each(["-m", "--model"])("rejects missing values for %s", async (flag) => { + const { command, launch } = program(); + await expect(command.parseAsync([flag], { from: "user" })).rejects.toThrow(); + expect(launch).not.toHaveBeenCalled(); + }); + + it.each(["", "bare-model", "provider/", "/model", "provider/model#"])( + "rejects malformed model %j before launch", + async (model) => { + const { command, launch } = program(); + await expect(command.parseAsync(["--model", model], { from: "user" })).rejects.toThrow( + "--model must use provider/model", + ); + expect(launch).not.toHaveBeenCalled(); + }, + ); + + it("rejects a model with an untargeted Session picker", async () => { + const { command, launch } = program(); + await expect( + command.parseAsync(["--session", "--model", "provider/model"], { from: "user" }), + ).rejects.toThrow("requires a Session id"); + expect(launch).not.toHaveBeenCalled(); + }); + + it("retains the default launch contract without a model", async () => { + const { command, launch } = program(); + await command.parseAsync(["hello"], { from: "user" }); + expect(launch).toHaveBeenCalledWith({ initialPrompt: "hello" }); + }); + + it.each(["-m", "--model"])("scans startup environment after %s values", (flag) => { + expect( + resolveTuiStartupEnvironmentOption([flag, "provider/model", "--env", "staging"], true), + ).toBe("staging"); + expect(() => + resolveTuiStartupEnvironmentOption([flag, "provider/model", "--env", "prod"], false), + ).toThrow("only available"); + expect( + resolveTuiStartupEnvironmentOption( + [flag, "provider/model", "exec", "--env", "staging"], + true, + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts new file mode 100644 index 000000000..10d95b164 --- /dev/null +++ b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts @@ -0,0 +1,305 @@ +import { stripVTControlCharacters } from "node:util"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ScrollView, + TuiAltScreen, + type Component, + type TuiMouseEvent, + visibleWidth, +} from "../../src/tui/engine/public.js"; +import { TuiChatLayout } from "../../src/tui/shell/chat-layout.js"; +import { VirtualTerminal } from "../pi-084-upstream/virtual-terminal.js"; + +const COLUMNS = 20; +const ROWS = 10; +const CONTENT_LINES = 60; +const screens: TuiAltScreen[] = []; +afterEach(() => { + for (const tui of screens.splice(0)) tui.stop(); +}); + +class ScrollableLines implements Component { + private readonly lines: string[] = []; + + constructor(count: number) { + for (let index = 0; index < count; index++) { + this.lines.push(`line-${String(index).padStart(2, "0")}`); + } + } + + render(): string[] { + return [...this.lines]; + } + + invalidate(): void {} +} + +function viewportText(terminal: VirtualTerminal): string { + return stripVTControlCharacters(terminal.getViewport().join("\n")); +} + +function sgrPress(x: number, y: number): string { + return `\x1b[<0;${x + 1};${y + 1}M`; +} + +function sgrMotion(x: number, y: number): string { + return `\x1b[<32;${x + 1};${y + 1}M`; +} + +function sgrRelease(x: number, y: number): string { + return `\x1b[<0;${x + 1};${y + 1}m`; +} + +async function createScrollbarScreen( + options: { + columns?: number; + gutter?: number; + content?: Component; + copySelection?: (text: string) => Promise; + } = {}, +): Promise<{ + terminal: VirtualTerminal; + tui: TuiAltScreen; + scrollView: ScrollView; +}> { + const terminal = new VirtualTerminal(options.columns ?? COLUMNS, ROWS); + const content = options.content ?? new ScrollableLines(CONTENT_LINES); + const scrollView = new ScrollView(content, { + primary: true, + overscroll: "contain", + scrollbar: "always", + scrollbarGutter: options.gutter ?? 3, + }); + const tui = new TuiAltScreen(terminal, false, undefined, { + copySelection: options.copySelection, + }); + screens.push(tui); + tui.setLayoutRoot(scrollView); + tui.start(); + await terminal.waitForRender(); + return { terminal, tui, scrollView }; +} + +describe("Fullscreen scrollbar interaction", () => { + it("shows the top of the content before any interaction", async () => { + const { terminal } = await createScrollbarScreen(); + expect(viewportText(terminal)).toContain("line-00"); + expect(viewportText(terminal)).not.toContain("line-59"); + }); + + it("jumps the thumb to a track press and keeps dragging from there", async () => { + const { terminal } = await createScrollbarScreen(); + // Press the empty track at the bottom of the right-edge scrollbar. + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-59"); + expect(viewportText(terminal)).not.toContain("line-00"); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + }); + + it("grabs near-miss clicks within the widened hit span", async () => { + const { terminal } = await createScrollbarScreen(); + // Press two columns left of the painted one-column bar. + terminal.sendInput(sgrPress(COLUMNS - 3, ROWS - 1)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-59"); + expect(viewportText(terminal)).not.toContain("line-00"); + terminal.sendInput(sgrRelease(COLUMNS - 3, ROWS - 1)); + await terminal.waitForRender(); + }); + + it("keeps an on-thumb press in place and drags proportionally", async () => { + const { terminal } = await createScrollbarScreen(); + // Jump to the end first via a track press at the bottom, then release. + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + // The thumb now spans the bottom rows; press it directly. + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 2)); + await terminal.waitForRender(); + // An on-thumb press must not move content on its own. + expect(viewportText(terminal)).toContain("line-59"); + // Drag the thumb up the track. + terminal.sendInput(sgrMotion(COLUMNS - 1, Math.floor(ROWS / 2))); + await terminal.waitForRender(); + const text = viewportText(terminal); + expect(text).not.toContain("line-59"); + expect(text).not.toContain("line-00"); + terminal.sendInput(sgrRelease(COLUMNS - 1, Math.floor(ROWS / 2))); + await terminal.waitForRender(); + }); + + it("ignores presses outside the hit span", async () => { + const { terminal } = await createScrollbarScreen(); + // Jump to the end via a track press, then release. + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + // Press three columns left of the bar, outside the hit span. + terminal.sendInput(sgrPress(COLUMNS - 4, 0)); + await terminal.waitForRender(); + terminal.sendInput(sgrRelease(COLUMNS - 4, 0)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-59"); + }); +}); + +describe("Scrollbar interaction boundaries", () => { + it.each([1, 3])( + "routes the last content columns with a %i-column gutter to content", + async (gutter) => { + const handleMouse = vi.fn((_event: TuiMouseEvent) => true); + const content = { + render: (width: number) => + Array.from({ length: CONTENT_LINES }, () => "x".repeat(width)), + invalidate() {}, + handleMouse, + }; + const { terminal, scrollView } = await createScrollbarScreen({ + gutter, + content, + }); + for (const x of [COLUMNS - gutter - 2, COLUMNS - gutter - 1]) { + terminal.sendInput(sgrPress(x, ROWS - 1)); + terminal.sendInput(sgrRelease(x, ROWS - 1)); + await terminal.waitForRender(); + expect(handleMouse).toHaveBeenCalledWith( + expect.objectContaining({ action: "press", x }), + ); + expect(scrollView.scrollTop).toBe(0); + } + }, + ); + + it.each([1, 3])( + "keeps text selection next to a %i-column gutter", + async (gutter) => { + const copySelection = vi.fn(async (_text: string) => true); + const content = { + render: (width: number) => + Array.from( + { length: CONTENT_LINES }, + () => "x".repeat(width - 2) + "YZ", + ), + invalidate() {}, + }; + const { terminal, scrollView } = await createScrollbarScreen({ + gutter, + content, + copySelection, + }); + terminal.sendInput(sgrPress(COLUMNS - gutter - 2, 4)); + terminal.sendInput(sgrMotion(COLUMNS - gutter - 1, 4)); + terminal.sendInput(sgrRelease(COLUMNS - gutter - 1, 4)); + await terminal.waitForRender(); + expect(copySelection).toHaveBeenCalledWith("YZ"); + expect(scrollView.scrollTop).toBe(0); + }, + ); + + it.each([1, 2, 3, 4, 8])( + "preserves content and bounded mouse targets at width %i", + async (columns) => { + const widths: number[] = []; + const handleMouse = vi.fn(() => true); + const content = { + render: (width: number) => { + widths.push(width); + return Array.from({ length: CONTENT_LINES }, () => "x".repeat(width)); + }, + invalidate() {}, + handleMouse, + }; + const { terminal, scrollView } = await createScrollbarScreen({ + columns, + content, + }); + expect(widths.every((width) => width === Math.max(1, columns - 3))).toBe( + true, + ); + expect( + terminal.getViewport().every((line) => visibleWidth(line) <= columns), + ).toBe(true); + terminal.sendInput(sgrPress(0, ROWS - 1)); + terminal.sendInput(sgrRelease(0, ROWS - 1)); + await terminal.waitForRender(); + expect(handleMouse).toHaveBeenCalled(); + expect(scrollView.scrollTop).toBe(0); + if (columns > 1) { + terminal.sendInput(sgrPress(columns - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(CONTENT_LINES - ROWS); + } + }, + ); + + it("does not capture a track press while an overlay is visible", async () => { + const { terminal, tui, scrollView } = await createScrollbarScreen(); + tui.showOverlay(new ScrollableLines(2), { width: 10 }); + await terminal.waitForRender(); + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(0); + }); + + it("cancels a drag when an overlay opens and does not resume it after dismissal", async () => { + const { terminal, tui, scrollView } = await createScrollbarScreen(); + terminal.sendInput(sgrPress(COLUMNS - 1, 0)); + const overlay = tui.showOverlay(new ScrollableLines(2), { width: 10 }); + await terminal.waitForRender(); + terminal.sendInput(sgrMotion(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(0); + overlay.hide(); + await terminal.waitForRender(); + terminal.sendInput(sgrMotion(COLUMNS - 1, ROWS - 1)); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(0); + }); + + it("routes wheel events normally and ends dragging on release", async () => { + const { terminal, scrollView } = await createScrollbarScreen(); + terminal.sendInput(`\x1b[<65;${COLUMNS};5M`); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBeGreaterThan(0); + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + terminal.sendInput(sgrMotion(COLUMNS - 1, 0)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(CONTENT_LINES - ROWS); + }); + + it("enables a reserved gutter in the actual fullscreen chat layout", async () => { + const terminal = new VirtualTerminal(40, 15); + const empty = { render: () => [], invalidate() {} }; + const layout = new TuiChatLayout(terminal, { + surface: () => "conversation", + transcript: new ScrollableLines(CONTENT_LINES), + welcome: empty, + interaction: { ...empty, isActive: () => false }, + activity: empty, + followUp: empty, + composer: { render: () => ["composer"], invalidate() {} }, + status: empty, + }); + const tui = new TuiAltScreen(terminal); + screens.push(tui); + tui.setLayoutRoot(layout.fullscreenLayoutRoot); + tui.start(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-59"); + terminal.sendInput(sgrPress(37, 0)); + terminal.sendInput(sgrRelease(37, 0)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + expect(viewportText(terminal)).not.toContain("line-59"); + expect(viewportText(terminal)).toContain("composer"); + }); +}); diff --git a/packages/tui/test/unit/tui-status-line-items.test.ts b/packages/tui/test/unit/tui-status-line-items.test.ts index 23ca62d79..090d07fcb 100644 --- a/packages/tui/test/unit/tui-status-line-items.test.ts +++ b/packages/tui/test/unit/tui-status-line-items.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { applyTuiRenderTheme, getTuiThemeSnapshot, tuiChalk, tuiColors } from '../../src/tui/theme/runtime.js'; +import { MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME } from '../../src/tui/theme/palettes.js'; import { TuiStatusLine } from '../../src/tui/shell/chrome.js'; import { stripAnsi, visibleWidth } from '../../src/tui/rendering/text.js'; import { setCapabilityOverrides } from '../../src/tui/engine/public.js'; @@ -582,3 +584,192 @@ describe('review link status item', () => { expect(narrow).toContain('!312'); }); }); + +describe('context meter item', () => { + it('resolves the canonical id and its aliases', () => { + expect(parseTuiStatusLineItem('context-meter')).toBe('context-meter'); + expect(parseTuiStatusLineItem('context-bar')).toBe('context-meter'); + expect(parseTuiStatusLineItem('context-gauge')).toBe('context-meter'); + }); + + it('stays out of the default status line', () => { + expect(TUI_STATUS_LINE_DEFAULT_ITEMS).toEqual([ + 'current-dir', 'session-title', 'git-branch', 'review-link', 'plan-mode', + 'approval-mode', 'model-with-reasoning', 'context-window', 'subagent', + 'token-quota', 'context-remaining', + ]); + const state = { + ...BASE_STATE, + contextUsage: { usedTokens: 20_000, contextWindowTokens: 100_000 }, + }; + expect(stripAnsi(render(state))).not.toContain('▕'); + }); + + it('renders the remaining-headroom gauge when configured explicitly', () => { + const state = { + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: 20_000, contextWindowTokens: 100_000 }, + }; + expect(stripAnsi(render(state))).toContain('Context ▕██████░░▏ 80% left'); + }); + + it('drains the gauge with the remaining headroom', () => { + const state = { + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: 50_000, contextWindowTokens: 100_000 }, + }; + expect(stripAnsi(render(state))).toContain('▕████░░░░▏ 50% left'); + }); + + it('shrinks to a shorter gauge in narrow terminals', () => { + const state = { + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: 50_000, contextWindowTokens: 100_000 }, + }; + const narrow = stripAnsi(render(state, 20)); + expect(narrow).toContain('Ctx'); + expect(narrow).toContain('50%'); + expect(narrow).not.toContain('left'); + expect(narrow).not.toContain('▕████░░░░▏'); + }); + + it('is hidden without usage and never shows NaN', () => { + expect(render({ ...BASE_STATE, statusLineItems: ['context-meter'], contextWindowTokens: 100_000 })).toBe(''); + expect( + stripAnsi( + render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: Number.NaN, contextWindowTokens: 100_000 }, + }), + ), + ).not.toContain('NaN'); + }); +}); + +describe('context meter presentation contract', () => { + it.each([ + ['context-remaining', 'model', 'context-bar', 'context-gauge'], + ['context-gauge', 'model', 'context-left'], + ])('prefers the meter at its configured position for %j', (...configured) => { + const statusLineItems = parseTuiStatusLineItems(configured); + const line = stripAnsi(render({ + ...BASE_STATE, + statusLineItems, + contextUsage: { usedTokens: 50_000, contextWindowTokens: 100_000 }, + })); + expect(line.match(/50%/gu)).toHaveLength(1); + expect(line).toContain('Context ▕████░░░░▏ 50% left'); + expect(line.indexOf('Context') < line.indexOf('m2')).toBe(configured[0] === 'context-gauge'); + }); +}); + +describe('context meter boundaries', () => { + it.each([ + [undefined, 100_000, 50], + [200_000, 100_000, 75], + [Number.NaN, 100_000, 50], + [0, 100_000, 50], + [-1, 100_000, 50], + [Number.POSITIVE_INFINITY, 100_000, 50], + ])('resolves snapshot window %s before model window %s', (snapshot, model, remaining) => { + for (const item of ['context-meter', 'context-remaining'] as const) { + const line = stripAnsi(render({ + ...BASE_STATE, + statusLineItems: [item], + contextWindowTokens: model, + contextUsage: { usedTokens: 50_000, contextWindowTokens: snapshot }, + })); + expect(line).toContain(`${remaining}% left`); + } + }); + + it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + 'hides the meter when neither window is usable (%s)', (window) => { + expect(render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextWindowTokens: window, + contextUsage: { usedTokens: 1, contextWindowTokens: window }, + })).toBe(''); + }, + ); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'hides invalid usage %s even with valid model capacity', (usedTokens) => { + expect(render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextWindowTokens: 100_000, + contextUsage: { usedTokens }, + })).toBe(''); + }, + ); + + it.each([ + [-1, '▕████████▏ 100% left'], + [0, '▕████████▏ 100% left'], + [100, '▕░░░░░░░░▏ 0% left'], + [150, '▕░░░░░░░░▏ 0% left'], + ])('clamps used tokens %d to a valid gauge', (usedTokens, expected) => { + expect(stripAnsi(render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens, contextWindowTokens: 100 }, + }))).toContain(expected); + }); + + it.each([ + [30, 'Context ▕████░░░░▏ 50% left'], + [20, 'Ctx ▕███░░░▏ 50%'], + [7, 'Ctx 50%'], + [6, ''], + ])('fits a width of %d with the expected presentation', (width, expected) => { + const line = render({ + ...BASE_STATE, + statusLineItems: ['context-meter'], + contextUsage: { usedTokens: 50, contextWindowTokens: 100 }, + }, width); + expect(stripAnsi(line).trim()).toBe(expected); + expect(line.split('\n').every((row) => visibleWidth(row) <= width)).toBe(true); + }); + + it('uses the same warning and error boundaries as the percentage item', () => { + const previous = getTuiThemeSnapshot(); + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + try { + for (const [remaining, color] of [ + [26, tuiColors.muted], [25, tuiColors.warning], + [11, tuiColors.warning], [10, tuiColors.error], [0, tuiColors.error], + ] as const) { + for (const item of ['context-meter', 'context-remaining'] as const) { + const line = render({ + ...BASE_STATE, + statusLineItems: [item], + contextUsage: { usedTokens: 100 - remaining, contextWindowTokens: 100 }, + }).trim(); + expect(line).not.toBe(stripAnsi(line)); + expect(line).toBe(tuiChalk.hex(color)(stripAnsi(line))); + } + } + } finally { + applyTuiRenderTheme( + previous.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME, + previous.colorLevel, + ); + } + }); + + it('keeps build-mode in control when the meter is selected', () => { + const line = stripAnsi(render({ + ...BASE_STATE, + statusLineItems: ['context-meter', 'build-mode', 'context-remaining'], + contextUsage: { usedTokens: 50, contextWindowTokens: 100 }, + })); + expect(line).toContain('[V]'); + expect(line).not.toContain('Context'); + }); +}); diff --git a/packages/tui/test/unit/tui-workspace-status-line.test.ts b/packages/tui/test/unit/tui-workspace-status-line.test.ts index 3d3f016f6..5c1cb47b3 100644 --- a/packages/tui/test/unit/tui-workspace-status-line.test.ts +++ b/packages/tui/test/unit/tui-workspace-status-line.test.ts @@ -508,3 +508,32 @@ describe('TuiWorkspaceStatusLine custom status command', () => { expect(runProcess).toHaveBeenCalledTimes(1); }); }); + + +describe('context meter projection', () => { + it('previews and refreshes current usage without retaining cleared data', () => { + const runtime = { getWorkspaceGitMetadata: vi.fn() }; + const state = { + version: '0.1', workspace: '/repo', runtimeStatus: 'ready' as const, + statusLineItems: ['context-meter', 'context-remaining'] as const, + contextWindowTokens: 100, + }; + const status = new TuiWorkspaceStatusLine(state, runtime, vi.fn()); + try { + expect(status.preview(['context-meter'], 80, 3).unavailable).toEqual(['context-meter']); + expect(status.render(80)).toEqual([]); + status.setState({ ...state, contextUsage: { usedTokens: 20 } }); + const preview = status.preview(['context-meter', 'context-remaining'], 80, 3); + expect(preview.unavailable).toEqual([]); + expect(preview.lines.join('')).toContain('▕██████░░▏ 80% left'); + expect(status.render(80).join('').match(/80%/gu)).toHaveLength(1); + status.setState({ ...state, contextUsage: { usedTokens: 90 } }); + expect(status.render(80).join('')).toContain('▕█░░░░░░░▏ 10% left'); + status.setState(state); + expect(status.render(80)).toEqual([]); + expect(runtime.getWorkspaceGitMetadata).not.toHaveBeenCalled(); + } finally { + status.dispose(); + } + }); +}); diff --git a/packages/tui/test/unit/tui/controller/product/command-flow.test.ts b/packages/tui/test/unit/tui/controller/product/command-flow.test.ts index c46265d89..d101bf8e1 100644 --- a/packages/tui/test/unit/tui/controller/product/command-flow.test.ts +++ b/packages/tui/test/unit/tui/controller/product/command-flow.test.ts @@ -37,6 +37,7 @@ function createReadinessCommandFlow(options: { }; persistTuiMode?: (mode: "regular" | "fullscreen") => void; reloadTui?: () => Promise; + append?: (text: string, kind?: "info" | "warning" | "error") => void; queuedCount?: number; }) { return new TuiCommandFlow({ @@ -86,7 +87,7 @@ function createReadinessCommandFlow(options: { ...(options.whenControllerReady ? { whenControllerReady: options.whenControllerReady } : {}), - append: vi.fn(), + append: options.append ?? vi.fn(), setHint: options.setHint ?? vi.fn(), onChanged: vi.fn(), }); @@ -347,6 +348,18 @@ describe("TuiCommandFlow", () => { expect(setHint).toHaveBeenLastCalledWith("TUI configuration reloaded."); }); + it.each(['Plugin refresh failed', 'Invalid keybindings.json'])("preserves /reload cause: %s", async (message) => { + const append = vi.fn(); + const flow = createReadinessCommandFlow({ + whenReady: async () => {}, append, + reloadTui: async () => { throw new Error(message); }, + }); + await expect(flow.submit('/reload')).resolves.toBe('consumed'); + expect(append).toHaveBeenCalledWith(expect.stringContaining(message), 'warning'); + expect(append).toHaveBeenCalledWith(expect.stringContaining('configuration or plugin refresh error'), 'warning'); + expect(JSON.stringify(append.mock.calls)).not.toContain('Fix keybindings.json'); + }); + it("retains /reload while queued work is present", async () => { const reloadTui = vi.fn(async () => undefined); const setHint = vi.fn(); diff --git a/packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts b/packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts index ff9ab3490..1103ef1b9 100644 --- a/packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts +++ b/packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts @@ -145,3 +145,20 @@ describe('status line picker', () => { expect(picker.render(120).join('\n')).not.toContain('0%'); }); }); + + +describe('context meter settings', () => { + it.each([ + ['en', 'Remaining context gauge'], + ['zh-Hans', '剩余上下文刻度条'], + ])('describes and saves the opt-in item in %s', async (locale, description) => { + const { picker, options } = fixture({ locale }); + picker.handleInput('context-meter'); + expect(picker.render(100).join('\n')).toContain(description); + expect(picker.render(100).join('\n')).toContain('[ ] context-meter'); + picker.handleInput(' '); + picker.handleInput('\r'); + await vi.waitFor(() => expect(options.onClose).toHaveBeenCalledOnce()); + expect(options.save).toHaveBeenCalledWith(['model', 'current-dir', 'context-meter']); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 4279497b0..6e96c8d26 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2243,6 +2243,7 @@ "packages/local-runtime/src/content-safety/api-v2.ts", "packages/local-runtime/src/content-safety/api.ts", "packages/local-runtime/src/content-safety/config-fields.ts", + "packages/local-runtime/src/content-safety/diagnostics.ts", "packages/local-runtime/src/content-safety/turn-api.ts", "packages/local-runtime/src/context/context-usage-calibration.ts", "packages/local-runtime/src/context/context-usage-provider-diagnostics.ts", @@ -3242,6 +3243,7 @@ "packages/tui/test/unit/tui-provider-manager.test.ts", "packages/tui/test/unit/tui-provider-onboarding.test.ts", "packages/tui/test/unit/tui-runtime-adapter.test.ts", + "packages/tui/test/unit/tui-scrollbar-interaction.test.ts", "packages/tui/test/unit/tui-session-manager.test.ts", "packages/tui/test/unit/tui-shell-block.test.ts", "packages/tui/test/unit/tui-status-line-items.test.ts", diff --git a/test/byok.test.mjs b/test/byok.test.mjs index 78facd3b4..64dba7eed 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -298,6 +298,7 @@ test( "provider", "add", "--name", "Limited", "--base-url", baseUrl, "--api-format", "openai-completions", "--model", "fixture-model", "--model", "second-model", "--context-limit", "32768", "--output-limit", "4096", "--use", + "--support-image", ]; const beforeFailure = readFileSync(configPath, "utf8"); rejectConnection = true; @@ -312,6 +313,7 @@ test( assert.equal(config.defaultModel, "custom_provider:limited/fixture-model"); for (const model of ["fixture-model", "second-model"]) { assert.deepEqual(config.custom_provider.limited.models[model].limit, { context: 32768, output: 4096 }); + assert.deepEqual(config.custom_provider.limited.models[model].capabilities, { support_image: true }); } const configured = JSON.parse(await run(["provider", "list", "--json"])).providers.find( (provider) => provider.name === "Limited", @@ -330,8 +332,10 @@ test( await run([ "provider", "add", "--name", name, "--base-url", baseUrl, "--api-format", "openai-completions", "--model", "fixture-model", flag, "32768", + "--support-image", ]); assert.deepEqual(savedConfig().custom_provider[name].models["fixture-model"].limit, limit); + assert.deepEqual(savedConfig().custom_provider[name].models["fixture-model"].capabilities, { support_image: true }); assert.equal(savedConfig().defaultModel, config.defaultModel); } assert.equal(requests.length, beforeSaveOnly, "Limits alone must not test or select a model"); @@ -342,7 +346,48 @@ test( reasoning: true, thinking: { effortOptions: ["low", "high", "max"] }, }; + effortConfig.custom_provider.fixture.models["vision-modalities"] = { + modalities: { input: ["text", "image"], output: ["text"] }, + }; + effortConfig.custom_provider.fixture.models["attachment-only"] = { attachment: true }; writeFileSync(configPath, stringifyYaml(effortConfig)); + // The packaged command must carry the declaration through saving, a fresh + // process/config reload, attachment preparation and SDK serialization. + const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGUlEQVR4nGP4n8LwnxLMMGrAqAGjBgwXAwCOqWIfmQV0zAAAAABJRU5ErkJggg=="; + const imagePath = path.join(workspaceDir, "fixture.png"); + writeFileSync(imagePath, Buffer.from(imageBase64, "base64")); + for (const [model, supportsImage] of [ + ["custom_provider:limited/fixture-model", true], + ["custom_provider:context-only/fixture-model", true], + ["custom_provider:fixture/vision-modalities", true], + ["custom_provider:fixture/fixture-model", false], + ["custom_provider:fixture/attachment-only", false], + ]) { + const beforeImage = requests.length; + await run(["exec", "IMAGE_INPUT_TEST", "--model", model, "--file", imagePath, + "--timeout", "20s", "--max-steps", "1"]); + const turns = requests.slice(beforeImage).filter((r) => r.body.stream === true); + assert.ok(turns.length > 0, `Expected a runtime request for ${model}`); + for (const { body } of turns) { + const images = body.messages.flatMap((message) => + Array.isArray(message.content) + ? message.content.filter((part) => part.type === "image_url") + : []); + if (supportsImage) { + assert.equal(images.length, 1, `Expected an inline image for ${model}`); + // PNG inputs are transcoded to JPEG by the real attachment pipeline. + const url = images[0].image_url.url; + assert.match(url, /^data:image\/jpeg;base64,/); + const bytes = Buffer.from(url.split(",")[1], "base64"); + assert.deepEqual([...bytes.subarray(0, 3)], [0xff, 0xd8, 0xff]); + assert.deepEqual([...bytes.subarray(-2)], [0xff, 0xd9]); + assert.doesNotMatch(JSON.stringify(body.messages), /current model cannot read this image inline/); + } else { + assert.deepEqual(images, [], `${model} must remain text-only`); + assert.match(JSON.stringify(body.messages), /current model cannot read this image inline/); + } + } + } const effortModel = `${selected.providerId}/kimi-k3`; for (const effort of ["low", "high", "max"]) { const beforeEffort = requests.length; diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index c1085e1aa..3b7a5f653 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -426,7 +426,7 @@ test('public support forms preserve destination URLs and separate Desktop from C for (const name of forms) { const form = parseYaml(readFileSync(new URL(`../.github/ISSUE_TEMPLATE/${name}`, import.meta.url), 'utf8')); const product = form.body.find(field => field.id === 'product'); - assert.equal(product.validations.required, true); + assert.equal(product.validations.required, name !== '03-question.yml'); assert.ok(product.attributes.options.includes('Desktop app')); assert.ok(product.attributes.options.includes('CLI - ACP')); assert.ok(product.attributes.options.includes('CLI - headless')); @@ -440,6 +440,24 @@ test('public support forms preserve destination URLs and separate Desktop from C assert.equal(classifyChanges(['README_ZH.md', 'README.md']).docsOnly, true); }); +test('issue forms label incoming reports for triage and retain collaborator-only PR guidance', () => { + for (const [name, type] of [ + ['01-bug-report.yml', 'bug'], ['02-feature-request.yml', 'enhancement'], + ['03-question.yml', 'question'], ['docs.yml', 'documentation'], + ]) { + const form = parseYaml(readFileSync(new URL(`../.github/ISSUE_TEMPLATE/${name}`, import.meta.url), 'utf8')); + assert.deepEqual(form.labels, [type, 'needs-triage']); + } + const config = parseYaml(readFileSync(new URL('../.github/ISSUE_TEMPLATE/config.yml', import.meta.url), 'utf8')); + assert.equal(config.blank_issues_enabled, false); + const policy = config.contact_links.find(link => link.url.endsWith('/CONTRIBUTING.md')); + assert.match(policy.about, /only from repository collaborators/); + assert.match(policy.about, /external PRs are not accepted/); + const question = parseYaml(readFileSync(new URL('../.github/ISSUE_TEMPLATE/03-question.yml', import.meta.url), 'utf8')); + assert.equal(question.body.find(field => field.id === 'platform').validations.required, false); + assert.equal(question.body.find(field => field.id === 'question').validations.required, true); +}); + test('issue notification is restricted to public destination events and builds payloads offline', t => { const workflow = parseYaml(readFileSync(new URL('../.github/workflows/sync-issue-to-feishu.yml', import.meta.url), 'utf8')); assert.deepEqual(workflow.on, { issues: { types: ['opened'] } }); diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 5404eeae7..5fe5dcf68 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -80,6 +80,7 @@ "packages/tui/test/unit/tui-chat-controller.test.ts", "packages/tui/test/unit/tui-duration.test.ts", "packages/tui/test/unit/tui-engine-local-deltas.test.ts", + "packages/tui/test/unit/tui-scrollbar-interaction.test.ts", "packages/tui/test/unit/tui-image-preview.test.ts", "packages/tui/test/unit/tui-session-manager.test.ts", "packages/tui/test/unit/tui-shell-block.test.ts", diff --git a/third_party/pi-mono/MINIMAX_CHANGES.md b/third_party/pi-mono/MINIMAX_CHANGES.md index 02ca14e7b..c9057b542 100644 --- a/third_party/pi-mono/MINIMAX_CHANGES.md +++ b/third_party/pi-mono/MINIMAX_CHANGES.md @@ -13,6 +13,40 @@ This directory vendors `pi-mono` as source so MiniMax can patch, validate, and s No upstream source files are changed in the baseline import. +### 2026-09-19 — preserve the system role for Mistral Chat Completions + +- Reason: thinking-enabled custom OpenAI-compatible connections to `api.mistral.ai` emitted `developer`, which is absent from the [Mistral Chat Completions message contract](https://docs.mistral.ai/api/endpoint/chat). [OpenClaw's compatibility defaults](https://github.com/openclaw/openclaw/blob/e2bcb1614de060927121bd72de850cee3a08d308/packages/ai/src/transports/openai-completions-compat.ts#L184-L210) also disable this role for the Mistral public endpoint. +- Affected package: `packages/ai` (`@earendil-works/pi-ai`), OpenAI Completions compatibility detection. +- Change: default to `system` only on the exact Mistral API host. Preserve thinking, explicit compatibility overrides, all other request options, and other endpoints. Native Mistral transport is unchanged. +- Upstream PR: not opened. +- Validation: outgoing-payload regressions in `packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts`; the endpoint regression fails before the fix. No live Mistral requests or full CLI acceptance were performed; the decision is based on the official contract and current upstream implementation. + +### 2026-09-19 — preserve the system role for SiliconFlow + +- Reason: thinking-enabled OpenAI-compatible requests sent the `developer` role, which is absent from SiliconFlow's documented message schema. +- Affected package: `packages/ai` (`@earendil-works/pi-ai`), OpenAI Completions compatibility detection. +- Change: default to the `system` role on the exact `api.siliconflow.cn` and `api.siliconflow.com` hosts. Preserve thinking, explicit compatibility overrides, other request options, and other endpoints. +- References: [official Chat Completions schema](https://docs.siliconflow.com/en/api-reference/chat-completions/chat-completions) and [pi's SiliconFlow provider proposal](https://github.com/earendil-works/pi/pull/8113) (closed without merging). +- Upstream PR: not opened. +- Validation: outgoing-payload regressions in `packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts`, including both official hosts, unrelated hosts, and explicit compatibility overrides. + +### 2026-09-19 — preserve the system role for DashScope + +- Reason: DashScope endpoints were treated as supporting the `developer` role, causing thinking-enabled OpenAI-compatible conversations to send an unsupported system-prompt role. +- Affected package: `packages/ai` (`@earendil-works/pi-ai`), OpenAI Completions compatibility detection. +- Change: default to the `system` role for official DashScope regional and Coding Plan hosts, plus workspace, trial, and Token Plan hosts under the documented regional `maas.aliyuncs.com` domains. Preserve thinking, other request options, explicit compatibility overrides, and other providers. +- References: [official endpoint list](https://help.aliyun.com/en/model-studio/base-url), [Coding Plan role rejection](https://github.com/openclaw/openclaw/issues/23575), [Token Plan role rejection](https://github.com/earendil-works/pi/issues/7723), and [merged OpenClaw DashScope fix](https://github.com/openclaw/openclaw/pull/24675). +- Upstream PR: not opened. +- Validation: request-payload regressions in `packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts`, including official endpoints, unrelated hosts, and explicit compatibility overrides. + +### 2026-09-19 — preserve the system role for Kimi Coding + +- Reason: Kimi Coding endpoints were treated as supporting the `developer` role, causing thinking-enabled OpenAI-compatible conversations to send an unsupported system-prompt role. +- Affected package: `packages/ai` (`@earendil-works/pi-ai`), OpenAI Completions compatibility detection. +- Change: default to the `system` role for `api.kimi.com` and `api.kimi.ai`. Preserve thinking, other request options, explicit compatibility overrides, and other providers. +- Upstream PR: not opened. +- Validation: offline request-payload regressions in `packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts`. Live Kimi Coding validation requires a Coding Plan key and remains untested. + ### 2026-08-31 — Windows PowerShell ConstrainedLanguage compatibility - Reason: the Windows PowerShell 5.1 stdin wrapper called `Parser.ParseInput`, `ScriptBlock.Create`, and other restricted .NET APIs before user commands. Under enterprise App Control / AppLocker `ConstrainedLanguage`, the wrapper therefore failed before commands such as Python could run. diff --git a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts index 20f3b9711..4d7b2d0fa 100644 --- a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts +++ b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts @@ -1090,6 +1090,12 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const isTogether = provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz"); const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot."); + const isKimiCoding = /^https?:\/\/api\.kimi\.(?:com|ai)(?::\d+)?(?:\/|$)/i.test(baseUrl); + const isDashScope = + /^https?:\/\/(?:dashscope(?:-intl|-us)?|(?:coding(?:-intl)?|cn-hongkong)\.dashscope)\.aliyuncs\.com(?::\d+)?(?:\/|$)/i.test(baseUrl) || + /^https?:\/\/[a-z0-9-]+\.(?:cn-beijing|cn-hongkong|ap-southeast-1|ap-northeast-1|eu-central-1|us-east-1)\.maas\.aliyuncs\.com(?::\d+)?(?:\/|$)/i.test(baseUrl); + const isSiliconFlow = /^https?:\/\/api\.siliconflow\.(?:cn|com)(?::\d+)?(?:\/|$)/i.test(baseUrl); + const isMistralApi = /^https?:\/\/api\.mistral\.ai(?::\d+)?(?:\/|$)/i.test(baseUrl); const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai"); const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); @@ -1124,7 +1130,12 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet return { supportsStore: !isNonStandard, - supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter), + supportsDeveloperRole: + !isKimiCoding && + !isDashScope && + !isSiliconFlow && + !isMistralApi && + (isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter)), supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing, supportsUsageInStreaming: true,