feat(agent): integrate selected upstream improvements (#237)
* feat(agent): integrate selected upstream improvements * test(modes): make resident completion batch setup deterministic * test(skills): use test volume root for Windows ancestor link
- commit
- ad3af576af6a785d5120bd6cedfdbb7372ba8150
- author
- bnema · · 2026-09-30 17:36 UTC
- committer
- GitHub · · 2026-09-30 17:36 UTC
- parent
- 4c37e30
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.48.0
92 files · +9955 -437
M README.md +12 -6
| @@ -12,7 +12,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||
| 12 | 12 | - seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries. | |
| 13 | 13 | - three run modes (interactive tui, print, json). | |
| 14 | 14 | - built-in telegram bot. | |
| 15 | - | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | |
| 15 | + | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. Interactive tools can wait for user input without a reply deadline and support cooperative cancellation; headless hosts reject them. See [docs/extensions.md](docs/extensions.md). | |
| 16 | 16 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | |
| 17 | 17 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | |
| 18 | 18 | - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md). | |
| @@ -113,11 +113,12 @@ Run `zut` and type `/login`. Pick one of two methods: | |||
| 113 | 113 | - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zut saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly. | |
| 114 | 114 | - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow. | |
| 115 | 115 | - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to. | |
| 116 | - | - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. | |
| 116 | + | - Anthropic uses the Claude Code OAuth flow, with separate browser callback and copy-code choices. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. | |
| 117 | + | - Browser login for Anthropic and OpenAI Codex accepts a pasted callback URL or code from the same transaction; a supplied state must match. `esc` cancels the pending flow. OpenAI Codex has no standalone manual-code login. | |
| 117 | 118 | - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. | |
| 118 | 119 | - Kimi uses the Kimi Code device-code OAuth flow. zut opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers. | |
| 119 | 120 | - xAI uses a device-code OAuth flow. zut opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API. | |
| 120 | - | - GitHub Copilot uses GitHub's device-code login flow. zut stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand. | |
| 121 | + | - GitHub Copilot uses GitHub's device-code login flow. zut stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand. Claude uses Messages, models tagged for Responses use that API, and the remaining models use Chat Completions. Account-specific model discovery filters picker availability without enabling policies; see [GitHub Copilot](docs/providers.md#github-copilot). | |
| 121 | 122 | ||
| 122 | 123 | > **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI, OpenAI's Codex CLI, Kimi Code CLI, xAI's device flow, and GitHub Copilot's device-code flow. Reusing them from a third-party tool may be against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default. | |
| 123 | 124 | ||
| @@ -387,8 +388,9 @@ Shows previous sessions for the current working directory, newest first, with ti | |||
| 387 | 388 | ||
| 388 | 389 | ### `/session` | |
| 389 | 390 | ||
| 390 | - | Four ops on the current session. `/session` alone opens a picker; each is also runnable directly. | |
| 391 | + | Inspect the current session with `/session info` or `/session timeline`, or export, import, fork, and browse its family tree. `/session` alone opens a picker; each operation is also runnable directly. | |
| 391 | 392 | ||
| 393 | + | - **`/session timeline`**. Opens a read-only floating inspector for the current system prompt, visible messages, reasoning summaries, and tool calls. Select an event with `up`/`down` or `pgup`/`pgdn`; `tab`/`shift+tab` switches between summary, payload, result, schema, and transcript-derived timing. Use `left`/`right` or `home`/`end` to scroll details, `/` to search, and `esc` to return. `ctrl+e` exports JSON to the default export directory with `0600` permissions where supported. Images are represented by MIME type and size; provider-private signatures and encrypted reasoning are omitted. The export still contains conversation and tool text: review it before sharing. Context composition is a byte-based estimate, not tokenizer-exact. | |
| 392 | 394 | - **`/session export [path]`**. Writes the running transcript to a portable `.zutsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zutsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zutsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout. | |
| 393 | 395 | ||
| 394 | 396 | **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/subagents` child journals are not included.** They remain machine-local managed state; a `.zutsession` is only a main-chat transcript. If you want the child conversation, copy it from the child-session view manually. | |
| @@ -510,7 +512,9 @@ Use `{}` for an empty position. If `active_model_profile` is missing, `0`, or ou | |||
| 510 | 512 | ||
| 511 | 513 | ### `/skills` | |
| 512 | 514 | ||
| 513 | - | Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately. | |
| 515 | + | Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately. User skill discovery follows directory symlinks, skips cycles and dangling links, and preserves nested path aliases. Extension skills remain confined to the extension directory. | |
| 516 | + | ||
| 517 | + | Press `p` or `g` in the list to toggle a project or global pin. Pinned skill bodies join the first user prompt of a fresh supported conversation; a read-only notice shows the pending selection without changing the editor. Resuming does not reload pins. Pins save repeated invocation, not tokens. See [pinning skills](docs/skills.md#pinning-skills) for storage, lifecycle, and mode support. | |
| 514 | 518 | ||
| 515 | 519 | ### `/compact` | |
| 516 | 520 | ||
| @@ -626,7 +630,9 @@ A top-level provider key that is not a built-in id defines a custom provider. Gi | |||
| 626 | 630 | } | |
| 627 | 631 | ``` | |
| 628 | 632 | ||
| 629 | - | Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. Supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call. | |
| 633 | + | Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. For authenticated endpoints, supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Custom endpoints with a configured URL can also run without a key. Set `"auth": "none"` on a custom provider to ignore inherited, CLI, environment, and stored credentials for inference and discovery; command-backed keys are not executed. Use this only with trusted keyless endpoints. Custom keys are saved without a verification probe; an invalid key surfaces on the first model call. | |
| 634 | + | ||
| 635 | + | Set `"discover": true` at the provider level for opt-in model discovery from its OpenAI-style `/models` endpoint. A provider-level `baseUrl` is required. Discovered models use conservative 32K context and 4K output defaults; explicit model entries override their metadata. Failed refreshes keep the previous in-memory list, and background discovery never executes command-backed credentials. See [custom model discovery](docs/providers.md#custom-model-discovery) for the schema and limits. | |
| 630 | 636 | ||
| 631 | 637 | To retrieve this custom provider's key from a password manager, add a matching entry to `$ZUT_HOME/auth.json`: | |
| 632 | 638 | ||
M docs/extensions.md +102 -1
| @@ -200,6 +200,12 @@ or any other stdout frame before `hello`. | |||
| 200 | 200 | "capabilities":["commands","tools","alerts","panels"]} | |
| 201 | 201 | ``` | |
| 202 | 202 | ||
| 203 | + | Include `"tool_cancel"` in `capabilities` to opt in to host cancellation | |
| 204 | + | notifications for abandoned tool calls (see `tool_cancel` below). The Go | |
| 205 | + | SDK advertises it automatically. The host only sends `tool_cancel` to | |
| 206 | + | extensions that advertised that exact capability; extensions that omit it | |
| 207 | + | never receive the frame and otherwise behave as before. | |
| 208 | + | ||
| 203 | 209 | #### `register_command` | |
| 204 | 210 | ||
| 205 | 211 | ```json | |
| @@ -242,6 +248,66 @@ Set `"deferred": true` to register a tool without advertising its definition ini | |||
| 242 | 248 | ||
| 243 | 249 | On Kimi K3's OpenAI-compatible routes, zut places newly activated schemas at the tool-result position using Kimi's native deferred-tool format. Other models receive the complete active tool list on the next request. Unknown names are ignored. The Go extension SDK exposes `DeferredTool` and `ToolResult.ActivateTools` for the same protocol. | |
| 244 | 250 | ||
| 251 | + | ##### Interactive tools | |
| 252 | + | ||
| 253 | + | Set `"interactive": true` on `register_tool` for a tool that intentionally | |
| 254 | + | waits for a person, for example one that opens a panel and returns once the | |
| 255 | + | user answers. Omitted or `false` keeps the normal 60-second reply timeout. | |
| 256 | + | ||
| 257 | + | ```json | |
| 258 | + | {"type":"register_tool","name":"ask_user", | |
| 259 | + | "description":"Ask the user a question","schema":{"type":"object"}, | |
| 260 | + | "interactive":true} | |
| 261 | + | ``` | |
| 262 | + | ||
| 263 | + | An interactive tool has no host-imposed reply deadline. The call still ends | |
| 264 | + | when the agent context is cancelled (user abort, turn deadline), when the | |
| 265 | + | extension disconnects, or when zut shuts down; each of those surfaces a tool | |
| 266 | + | error to the model. Only the interactive terminal host can run interactive | |
| 267 | + | tools. Print, JSON, RPC, and bot hosts reject the call with a tool error instead | |
| 268 | + | of waiting on an invisible panel. Scheduled background sessions exclude | |
| 269 | + | interactive tools from their catalog, even when the shared extension manager is | |
| 270 | + | attached to the foreground TUI. Resident subagents also exclude them and reject | |
| 271 | + | a declared interactive tool during assembly. Ordinary extension tools retain | |
| 272 | + | their existing mode-specific availability. | |
| 273 | + | ||
| 274 | + | Interactive tools also run from `/btw` and an interactive conversation mirrored | |
| 275 | + | to Telegram, but their panels appear only in the local TUI, not in Telegram. | |
| 276 | + | A Telegram-only user must use `/stop` to cancel a waiting main-turn call; | |
| 277 | + | `/btw` calls are cancelled with Escape in the local TUI. Panels use one shared | |
| 278 | + | slot: opening another panel replaces the visible one without notifying | |
| 279 | + | the previous owner. Extension handlers must honor cancellation and must not | |
| 280 | + | rely on simultaneous panels. | |
| 281 | + | ||
| 282 | + | The Go SDK provides a context-aware handler: | |
| 283 | + | ||
| 284 | + | ```go | |
| 285 | + | e.InteractiveTool("ask_user", "Ask the user a question", schema, | |
| 286 | + | func(ctx context.Context, args json.RawMessage) ext.ToolResult { | |
| 287 | + | // Open your panel and arrange for its callbacks to deliver an answer. | |
| 288 | + | // Use invocation-local state, including a unique panel ID. | |
| 289 | + | defer closeQuestionPanel() | |
| 290 | + | select { | |
| 291 | + | case answer := <-answers: | |
| 292 | + | return ext.TextResult(answer) | |
| 293 | + | case <-ctx.Done(): | |
| 294 | + | return ext.TextErrorResult("question cancelled") | |
| 295 | + | } | |
| 296 | + | }) | |
| 297 | + | ``` | |
| 298 | + | ||
| 299 | + | `schema`, `answers`, and `closeQuestionPanel` are extension-owned values. | |
| 300 | + | Panel dismissal must also resolve the handler's wait. Cancellation is | |
| 301 | + | cooperative: the SDK cancels the handler's `ctx` on `tool_cancel`, on | |
| 302 | + | `shutdown`, and when the host connection ends, and it discards a result the | |
| 303 | + | handler returns after cancellation, but it cannot stop a handler that ignores | |
| 304 | + | `ctx`. Existing `Tool` and `DeferredTool` handlers are unchanged and remain | |
| 305 | + | source-compatible. | |
| 306 | + | ||
| 307 | + | The `interactive` field is additive and does not change the protocol | |
| 308 | + | version. Older hosts ignore it and apply their normal timeout. Both host and | |
| 309 | + | extension must support the feature for a cancellable, unlimited wait. | |
| 310 | + | ||
| 245 | 311 | #### `ready` | |
| 246 | 312 | ||
| 247 | 313 | Sentinel telling zut "all initial registrations are flushed". Send it | |
| @@ -541,7 +607,42 @@ responsible for validating/coercing it. | |||
| 541 | 607 | ||
| 542 | 608 | Reply with `tool_result` within the host's tool timeout (default 60s). | |
| 543 | 609 | Missing the timeout surfaces an error to the model and the call is | |
| 544 | - | marked as failed. | |
| 610 | + | marked as failed. Tools registered with `"interactive": true` have no reply | |
| 611 | + | timeout (see *Interactive tools* above). | |
| 612 | + | ||
| 613 | + | The request write itself is always bounded, independently of the reply | |
| 614 | + | deadline: host frames are queued in order, synchronous writes have a | |
| 615 | + | five-second timeout, and a write timeout disconnects the extension. The queue is | |
| 616 | + | bounded to 256 frames and 16 MiB; overflowing it disconnects the extension | |
| 617 | + | rather than dropping frames silently. If the agent cancels a call | |
| 618 | + | while its `tool_call` frame is still queued, the frame is withdrawn and the | |
| 619 | + | transport stays healthy. If writing has begun, the host allows up to 250 ms | |
| 620 | + | for that frame to finish, then disconnects if it is still blocked: a torn frame | |
| 621 | + | cannot be withdrawn, and other pending calls on that extension fail too. | |
| 622 | + | ||
| 623 | + | Every `tool_call` carries a correlation `id` that is unique within the host | |
| 624 | + | process. A `tool_result` for an id the host no longer waits on (timed out, | |
| 625 | + | cancelled, or already answered) is ignored. | |
| 626 | + | ||
| 627 | + | #### `tool_cancel` | |
| 628 | + | ||
| 629 | + | Sent best-effort when a delivered `tool_call` is abandoned because the agent | |
| 630 | + | context was cancelled or the reply timeout elapsed, and only to extensions | |
| 631 | + | that advertised `"tool_cancel"` in `hello.capabilities`. Normal completion | |
| 632 | + | never sends it. `id` is the `tool_call` correlation id, not a transcript | |
| 633 | + | tool id. | |
| 634 | + | ||
| 635 | + | ```json | |
| 636 | + | {"type":"tool_cancel","id":"..."} | |
| 637 | + | ``` | |
| 638 | + | ||
| 639 | + | Stop the associated work, close any panel it opened, and discard its pending | |
| 640 | + | state. Ignore unknown or already-completed ids. `tool_cancel` may arrive | |
| 641 | + | before your handler goroutine has started, so register cancellation state | |
| 642 | + | before dispatching work. Delivery is not guaranteed after a transport | |
| 643 | + | disconnect; extensions must also clean up on `shutdown` and on stdin EOF. | |
| 644 | + | The host ignores late results for abandoned calls, including races with | |
| 645 | + | cancellation. | |
| 545 | 646 | ||
| 546 | 647 | #### `event` | |
| 547 | 648 | ||
M docs/providers.md +92 -5
| @@ -90,6 +90,38 @@ These providers support subscription login: | |||
| 90 | 90 | OAuth tokens are stored in `$ZUT_HOME/auth.json` and refreshed when refresh is | |
| 91 | 91 | available. | |
| 92 | 92 | ||
| 93 | + | Anthropic offers browser callback and copy-code login as separate choices. | |
| 94 | + | Browser login for Anthropic and OpenAI Codex also accepts a pasted callback URL | |
| 95 | + | or code using the same authorization transaction; it does not start a second | |
| 96 | + | login. A supplied state must match the pending transaction. Escape cancels the | |
| 97 | + | pending flow. OpenAI Codex requires the browser callback transaction and has no | |
| 98 | + | standalone manual-code login. | |
| 99 | + | ||
| 100 | + | ### GitHub Copilot | |
| 101 | + | ||
| 102 | + | Copilot uses the account's short-lived inference token. Claude models use the | |
| 103 | + | Anthropic Messages API with Copilot Bearer authentication, without Claude Code | |
| 104 | + | identity headers. Newer GPT, Grok, and MAI models use Responses; older GPT-4 and | |
| 105 | + | Grok Code models remain on Chat Completions. Gemini and Kimi use Chat Completions | |
| 106 | + | without `reasoning_effort`. Requests carry user/agent initiator metadata and an | |
| 107 | + | image request header when appropriate. | |
| 108 | + | ||
| 109 | + | Authenticated model discovery filters the runtime catalog by picker visibility, | |
| 110 | + | policy state, and tool-call support. The individual-account endpoint may fall | |
| 111 | + | back to explicitly enabled policies when no eligible picker entries exist; | |
| 112 | + | other endpoints cannot. Zut never enables policies automatically. Availability | |
| 113 | + | is a selection aid, not an authorization guarantee, and remains in memory rather | |
| 114 | + | than the shared model cache. Failed discovery retains the previous snapshot. | |
| 115 | + | Background discovery does not run command-backed credentials. Until discovery | |
| 116 | + | succeeds, the static catalog is visible. Existing sessions and explicit model | |
| 117 | + | selection still resolve catalog metadata for models hidden from the picker. | |
| 118 | + | The default Copilot model is `claude-sonnet-5`. User model overrides retain | |
| 119 | + | Copilot protocol defaults unless an explicit API or `reasoningLevelMap` overrides | |
| 120 | + | them. For Copilot rows, the `anthropic` and `openai` API aliases select Messages | |
| 121 | + | and Chat Completions respectively. Claude 4.5/4.6 rows use explicit thinking | |
| 122 | + | budgets. The retired | |
| 123 | + | `mai-code-1-flash-picker` is not in the static catalog; `mai-code-1.1-flash` is. | |
| 124 | + | ||
| 93 | 125 | ## API-key providers | |
| 94 | 126 | ||
| 95 | 127 | These providers can use environment variables. Simple API-key providers can | |
| @@ -478,11 +510,18 @@ unknown `api` value falls back to `openai` with a warning. | |||
| 478 | 510 | ``` | |
| 479 | 511 | ||
| 480 | 512 | Custom providers are first-class: they appear in `--list-models`, `/model`, and | |
| 481 | - | `/login`. `models.json` never stores secrets. Supply the key through `/login`, | |
| 482 | - | `--api-key`, or a derived environment variable in upper snake case, so | |
| 483 | - | `my-company` reads `MY_COMPANY_API_KEY`. Because many self-hosted gateways do | |
| 484 | - | not expose a model-list endpoint, custom provider keys are accepted and stored | |
| 485 | - | without a verification probe; an invalid key surfaces on the first model call. | |
| 513 | + | `/login`. `models.json` never stores secrets. For authenticated endpoints, supply | |
| 514 | + | the key through `/login`, `--api-key`, or a derived environment variable in upper | |
| 515 | + | snake case, so `my-company` reads `MY_COMPANY_API_KEY`. Custom endpoints with a | |
| 516 | + | configured base URL can also run without a key. Set `"auth": "none"` on a custom | |
| 517 | + | provider to ignore inherited, CLI, environment, and stored credentials, including | |
| 518 | + | command-backed keys, for inference and discovery. Use this only with trusted | |
| 519 | + | keyless endpoints. Built-in providers retain their own authentication rules; | |
| 520 | + | setting `auth: "none"` on a built-in provider is ignored with a warning. | |
| 521 | + | ||
| 522 | + | Custom provider keys are accepted and stored without a verification probe, | |
| 523 | + | because many self-hosted gateways do not expose a model-list endpoint; an invalid | |
| 524 | + | key surfaces on the first model call. | |
| 486 | 525 | ||
| 487 | 526 | To retrieve this custom provider's key from a password manager, add a matching | |
| 488 | 527 | entry to `$ZUT_HOME/auth.json`: | |
| @@ -508,6 +547,54 @@ custom model directly: | |||
| 508 | 547 | zut --provider my-company --model company-llm-v2 | |
| 509 | 548 | ``` | |
| 510 | 549 | ||
| 550 | + | ### Custom model discovery | |
| 551 | + | ||
| 552 | + | Set `"discover": true` at the custom provider level to list models from its | |
| 553 | + | OpenAI-style `/models` endpoint. Discovery is off by default and requires a | |
| 554 | + | provider-level `baseUrl`; static model entries override discovered metadata. | |
| 555 | + | For a local keyless server: | |
| 556 | + | ||
| 557 | + | ```json | |
| 558 | + | { | |
| 559 | + | "providers": { | |
| 560 | + | "local-gateway": { | |
| 561 | + | "baseUrl": "http://127.0.0.1:48000/v1", | |
| 562 | + | "api": "openai", | |
| 563 | + | "auth": "none", | |
| 564 | + | "discover": true, | |
| 565 | + | "models": [ | |
| 566 | + | { "id": "local-model", "contextWindow": 65536, "maxTokens": 8192 } | |
| 567 | + | ] | |
| 568 | + | } | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | ``` | |
| 572 | + | ||
| 573 | + | Zut requests `<baseUrl>/models`, or `<baseUrl>/v1/models` when the base has no | |
| 574 | + | version suffix, with a three-second timeout and an eight-MiB response limit. | |
| 575 | + | The response must contain `{"data": [{"id": "model-id"}]}`. Discovery trusts IDs | |
| 576 | + | only: default metadata is 32,768 context tokens, 4,096 output tokens, zero pricing, | |
| 577 | + | and no reasoning controls. IDs containing `embed` are skipped. Configure explicit | |
| 578 | + | model metadata to raise limits or enable reasoning. Discovery always verifies | |
| 579 | + | TLS certificates; `--insecure` and the saved insecure setting apply to inference, | |
| 580 | + | not discovery. For self-signed servers, install a trusted certificate chain. | |
| 581 | + | ||
| 582 | + | Discovery runs in the background at startup and when `/model` opens. Launching | |
| 583 | + | with an unknown model from a discovery-enabled provider probes only that | |
| 584 | + | selected provider, without touching other providers' endpoints or key commands. | |
| 585 | + | `/model` refreshes configured llama.cpp and custom catalogs with independent three-second | |
| 586 | + | timeouts and still opens the picker if a refresh fails, showing the error. | |
| 587 | + | Lists stay in memory, separate from the cloud model cache. Failed refreshes | |
| 588 | + | retain the previous provider snapshot. Background discovery never executes an | |
| 589 | + | `api_key_command` and skips a provider whose only key is command-backed; an | |
| 590 | + | explicit refresh may execute that command. The refresh budget also bounds the | |
| 591 | + | key command; a slow or interactive password-manager prompt can time out. | |
| 592 | + | Environment or stored keys are used when available unless `auth` is `none`; no | |
| 593 | + | key is required for a keyless server. If an explicitly selected custom model is | |
| 594 | + | unknown and discovery fails, inference can still attempt it with the existing | |
| 595 | + | 128,000-context / 16,384-output fallback. Configure static model limits to avoid | |
| 596 | + | relying on either fallback. | |
| 597 | + | ||
| 511 | 598 | ## Credential resolution | |
| 512 | 599 | ||
| 513 | 600 | For each request, zut checks credentials in this order: | |
M docs/skills.md +100 -1
| @@ -79,7 +79,26 @@ install` and loaded automatically. User/project skills take precedence over | |||
| 79 | 79 | bundled skills; bundled skills take precedence over embedded built-ins. When | |
| 80 | 80 | two extensions provide the same skill name, the first declaration wins. The | |
| 81 | 81 | host rejects manifest paths that escape the extension directory, including | |
| 82 | - | symlinked skill files that resolve outside it. | |
| 82 | + | symlinked skill files that resolve outside it. Skill directories inside a | |
| 83 | + | bundled directory may be symlinks, but only while they resolve inside the | |
| 84 | + | extension directory; a link that leaves it is reported and skipped. Real | |
| 85 | + | directories load before links, a directory reached through both a real path and | |
| 86 | + | a link loads once, and only direct children of the declared directory are | |
| 87 | + | considered. | |
| 88 | + | ||
| 89 | + | User and project skill roots may themselves be symlinks, and so may the | |
| 90 | + | directories inside them, so a checkout can live in one place and be linked into | |
| 91 | + | a skills directory. Real directories are scanned before links, so a link cannot | |
| 92 | + | take a name or slash alias ahead of the real tree. A directory reached twice | |
| 93 | + | through links is scanned once, link cycles are ignored, and a link that points | |
| 94 | + | at nothing is skipped silently. A link to the skills directory itself or to any | |
| 95 | + | of its parents (for example `~` or `/`) is ignored, so it cannot pull the | |
| 96 | + | surrounding filesystem into discovery; links to unrelated shared checkouts work | |
| 97 | + | normally. Each skills directory is scanned to at most 64 levels and 10,000 | |
| 98 | + | directories; beyond that zut reports a warning, keeps the skills already found, | |
| 99 | + | and skips the rest. Other failures, such as a permission error on a | |
| 100 | + | link target, are reported. A skill found through a link reports the installed | |
| 101 | + | (link) path, and its slash alias follows the path it was reached by. | |
| 83 | 102 | ||
| 84 | 103 | When `XDG_STATE_HOME` is set on any platform, `$ZUT_HOME` defaults to | |
| 85 | 104 | `$XDG_STATE_HOME/zut`. Otherwise it defaults to `~/Library/Application Support/zut/` | |
| @@ -91,6 +110,86 @@ In zut, run `/skills`. A picker lists every discovered skill with its | |||
| 91 | 110 | description and source path. Press enter on a row to view the full | |
| 92 | 111 | body inline. Press esc to go back. | |
| 93 | 112 | ||
| 113 | + | ## Pinning skills | |
| 114 | + | ||
| 115 | + | In the `/skills` list, press `p` to toggle a project pin or `g` to toggle a | |
| 116 | + | global pin. Rows show `[p-]`, `[-g]`, or `[pg]` for the selected scopes. Global | |
| 117 | + | and project pins are combined in alphabetical order of canonical skill name, | |
| 118 | + | with each skill loaded only once even when pinned under both its name and a | |
| 119 | + | nested slash alias. Unpinning one scope leaves the other intact. When the host | |
| 120 | + | provides no pin preference callbacks, the picker is read-only: no markers are | |
| 121 | + | shown and `p`/`g` do nothing. A failed save reports an error without changing | |
| 122 | + | preferences. | |
| 123 | + | ||
| 124 | + | On a fresh interactive session, a read-only `[Pinned skills]` section above the | |
| 125 | + | input lists the skills that will accompany your next message, each with its | |
| 126 | + | scope: `(project)`, `(global)`, or `(project + global)`. Opening zut does not | |
| 127 | + | start a model turn. The complete skill bodies and their directories are | |
| 128 | + | included in your first genuine user message and persisted with it, without | |
| 129 | + | changing the editor text. `/clear` prepares pins again, as does `/cd` for the | |
| 130 | + | new directory. Resuming, importing, or forking an existing session does not | |
| 131 | + | inject pins, even when its transcript is empty. Changing models and compacting | |
| 132 | + | context do not reload pins. | |
| 133 | + | ||
| 134 | + | Changing pins before the first message updates the pending selection; after it, | |
| 135 | + | changes apply on the next fresh conversation or `/clear`. Unpinning does not | |
| 136 | + | remove instructions already in the transcript. A queued user prompt carries the | |
| 137 | + | preload once. Recalling or discarding it before delivery restores the raw draft | |
| 138 | + | and leaves pins pending for the next user submission; injected bodies are not | |
| 139 | + | inserted into the editor. A failed or cancelled pre-turn compaction also leaves | |
| 140 | + | the undelivered selection pending for the next user submission. Recall restores | |
| 141 | + | the selection already attached to that draft; pin changes made after its | |
| 142 | + | submission still apply on the next fresh conversation or `/clear`. | |
| 143 | + | ||
| 144 | + | Pins are persisted as part of the first user message. Session previews and | |
| 145 | + | export filename slugs derived from that message can therefore show the pin | |
| 146 | + | preamble rather than the user's request. Input history and queued-draft recall | |
| 147 | + | show the original request. | |
| 148 | + | ||
| 149 | + | Preferences live in `$ZUT_HOME/skill-pins.json`, never in the repository: | |
| 150 | + | ||
| 151 | + | ```json | |
| 152 | + | { | |
| 153 | + | "global": ["code-review"], | |
| 154 | + | "projects": { | |
| 155 | + | "/workspace/project": ["test-fix"] | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ``` | |
| 159 | + | ||
| 160 | + | Project keys are the absolute starting directory with symlinks resolved when | |
| 161 | + | possible, not the Git root, so subdirectories have separate project pins and a | |
| 162 | + | symlinked alias shares the pins of its target. Pins store discovered skill | |
| 163 | + | names, not copies of skill files, so normal discovery precedence applies. | |
| 164 | + | Missing or disabled skills produce a warning and are skipped. A malformed | |
| 165 | + | preferences file is reported and is never overwritten by a toggle; fix or | |
| 166 | + | remove it. The file must contain a JSON object, so `null`, | |
| 167 | + | arrays, and scalars count as malformed. Only the `global` and `projects` fields | |
| 168 | + | are supported: any other top-level field is reported as an unsupported field and | |
| 169 | + | the file is left untouched, so unknown data is never silently dropped by a save. | |
| 170 | + | If `skill-pins.json` is a symbolic link, zut reads pins through it but refuses | |
| 171 | + | to save (the link and its target are never replaced or modified), so pinning | |
| 172 | + | from `/skills` reports an error; make the file a regular file to change pins | |
| 173 | + | from zut. Saves replace the file through a temporary file | |
| 174 | + | in the same directory. Concurrent edits from separate zut processes are not | |
| 175 | + | merged, and an open session refreshes preferences on `/skills` and `/clear`, | |
| 176 | + | not continuously. | |
| 177 | + | ||
| 178 | + | `--no-skill` disables pin preloading. Pinning a skill that sets | |
| 179 | + | `disable-model-invocation: true` is allowed. Print, stream, and JSON modes also | |
| 180 | + | include pins with the first prompt of a fresh session, including runs with | |
| 181 | + | session persistence disabled; zutfile startup `pre` commands run first, and | |
| 182 | + | warnings go to stderr. Resumed sessions get nothing. Explicit `--orchestrate` | |
| 183 | + | runs, RPC, the SDK, standalone bot modes, and resident background workers do not | |
| 184 | + | load pin preferences. The | |
| 185 | + | interactive Telegram bridge uses the interactive conversation's pins. | |
| 186 | + | ||
| 187 | + | **Token cost:** pinning saves repeated invocation, not tokens. Every pinned | |
| 188 | + | skill's complete body is added once to the first message and stays in the | |
| 189 | + | context window (and in every later request) until compaction summarizes it, | |
| 190 | + | unlike the on-demand `skill` tool where only the short manifest is always | |
| 191 | + | present. Keep pins small, especially with local models. | |
| 192 | + | ||
| 94 | 193 | ## Invoking skills | |
| 95 | 194 | ||
| 96 | 195 | For normal skills, the system prompt tells the model the skill names and | |
M packages/agent/botcmd.go +11 -1
| @@ -280,7 +280,7 @@ func botRun(spec *botSpec, rawTail []string, version string) (runErr error) { | |||
| 280 | 280 | } | |
| 281 | 281 | defer closeResolvedLSP(next) | |
| 282 | 282 | agent.Client = next.NewClient() | |
| 283 | - | agent.Model = next.Model | |
| 283 | + | applyResolvedModel(agent, next) | |
| 284 | 284 | runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD) | |
| 285 | 285 | return nil | |
| 286 | 286 | }, | |
| @@ -532,3 +532,13 @@ func telegramBotReset() error { | |||
| 532 | 532 | fmt.Println("removed", p) | |
| 533 | 533 | return nil | |
| 534 | 534 | } | |
| 535 | + | ||
| 536 | + | // applyResolvedModel adopts a reloaded model's ID and limits together so a | |
| 537 | + | // model change does not keep the previous model's output budget. | |
| 538 | + | func applyResolvedModel(agent *core.Agent, next Resolved) { | |
| 539 | + | agent.Model = next.Model | |
| 540 | + | agent.MaxTokens = next.MaxOutput | |
| 541 | + | if next.ContextWindow > 0 { | |
| 542 | + | agent.ContextWindow = next.ContextWindow | |
| 543 | + | } | |
| 544 | + | } | |
A packages/agent/botcmd_limits_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/core" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestApplyResolvedModelAdoptsLimits(t *testing.T) { | |
| 10 | + | ag := core.NewAgent(nil, "old", "", nil) | |
| 11 | + | ag.MaxTokens = 4096 | |
| 12 | + | ag.ContextWindow = 8000 | |
| 13 | + | ||
| 14 | + | applyResolvedModel(ag, Resolved{Model: "new", MaxOutput: 64000, ContextWindow: 200000}) | |
| 15 | + | if ag.Model != "new" || ag.MaxTokens != 64000 || ag.ContextWindow != 200000 { | |
| 16 | + | t.Fatalf("agent = model %q max %d ctx %d", ag.Model, ag.MaxTokens, ag.ContextWindow) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Missing output metadata must not keep the previous model's cap; | |
| 20 | + | // a missing context window keeps the existing one. | |
| 21 | + | applyResolvedModel(ag, Resolved{Model: "other"}) | |
| 22 | + | if ag.Model != "other" || ag.MaxTokens != 0 || ag.ContextWindow != 200000 { | |
| 23 | + | t.Fatalf("agent = model %q max %d ctx %d", ag.Model, ag.MaxTokens, ag.ContextWindow) | |
| 24 | + | } | |
| 25 | + | } | |
M packages/agent/build.go +56 -9
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "sort" | |
| 10 | 10 | "strings" | |
| 11 | 11 | "sync" | |
| 12 | + | "time" | |
| 12 | 13 | ||
| 13 | 14 | zutdocs "github.com/bnema/zut" | |
| 14 | 15 | "github.com/bnema/zut/packages/agent/lsp" | |
| @@ -69,6 +70,11 @@ type Resolved struct { | |||
| 69 | 70 | // client construction does not reread a mutable global registry. | |
| 70 | 71 | customProviderConfig *provider.CustomProviderConfig | |
| 71 | 72 | ||
| 73 | + | // credentialOptional marks a configured custom endpoint that may run | |
| 74 | + | // without any API credential (keyless). Credential stays empty then, so | |
| 75 | + | // clients send no Authorization header. | |
| 76 | + | credentialOptional bool | |
| 77 | + | ||
| 72 | 78 | // SkillTool is the on-demand skill loader registered with the | |
| 73 | 79 | // agent's tool registry, or nil if no SKILL.md files were | |
| 74 | 80 | // discovered. Exposed so the tui can list / preview skills. | |
| @@ -93,8 +99,11 @@ type Resolved struct { | |||
| 93 | 99 | toolDescriptions map[string]string | |
| 94 | 100 | } | |
| 95 | 101 | ||
| 96 | - | // HasCredential reports whether a credential was resolved. | |
| 97 | - | func (r Resolved) HasCredential() bool { return r.Credential != "" } | |
| 102 | + | // HasCredential reports whether the resolved configuration can create a client. | |
| 103 | + | // Configured custom endpoints may intentionally omit an API credential. | |
| 104 | + | func (r Resolved) HasCredential() bool { | |
| 105 | + | return r.Credential != "" || r.credentialOptional | |
| 106 | + | } | |
| 98 | 107 | ||
| 99 | 108 | // ModelCatalogSnapshot returns the runtime-owned model metadata captured while | |
| 100 | 109 | // resolving. It is used by SDK clients whose provider catalog is scoped to a | |
| @@ -235,6 +244,7 @@ type ExtensionToolInfo struct { | |||
| 235 | 244 | Description string | |
| 236 | 245 | Schema []byte | |
| 237 | 246 | Deferred bool | |
| 247 | + | Interactive bool | |
| 238 | 248 | } | |
| 239 | 249 | ||
| 240 | 250 | // toolSummariesFromRegistry rebuilds the system-prompt tool list | |
| @@ -343,7 +353,7 @@ func defaultModelForProvider(prov string) string { | |||
| 343 | 353 | case provider.ProviderAzureOpenAIResponses: | |
| 344 | 354 | return "gpt-5" | |
| 345 | 355 | case provider.ProviderGitHubCopilot: | |
| 346 | - | return "claude-sonnet-4.5" | |
| 356 | + | return "claude-sonnet-5" | |
| 347 | 357 | default: | |
| 348 | 358 | // Custom providers: pick the first model from the catalog for | |
| 349 | 359 | // that provider, or fall back to the global default. | |
| @@ -590,6 +600,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 590 | 600 | repairConfig = true | |
| 591 | 601 | } | |
| 592 | 602 | ||
| 603 | + | // An explicit auth:"none" custom provider ignores CLI, env, and stored | |
| 604 | + | // credentials entirely and never runs a stored api-key command. | |
| 605 | + | customCfg, customProvider := provider.CustomProviders()[provName] | |
| 606 | + | keylessCustom := customProvider && !isBuiltinProvider(provName) && customCfg.NoAuth | |
| 607 | + | ||
| 593 | 608 | var ( | |
| 594 | 609 | cred string | |
| 595 | 610 | method string | |
| @@ -601,7 +616,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 601 | 616 | // "ollama" credential never triggers the cloud default. | |
| 602 | 617 | hasRealOllamaCred bool | |
| 603 | 618 | ) | |
| 604 | - | if provName == "ollama" { | |
| 619 | + | if keylessCustom { | |
| 620 | + | // Explicit keyless configuration takes precedence over CLI, env, and | |
| 621 | + | // stored credentials. Never execute a stored API key command. | |
| 622 | + | cred, method, accountID, credErr = "", "", "", nil | |
| 623 | + | } else if provName == "ollama" { | |
| 605 | 624 | cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) | |
| 606 | 625 | if credErr != nil { | |
| 607 | 626 | // Local ollama needs no key: fall back to the dummy | |
| @@ -616,7 +635,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 616 | 635 | ||
| 617 | 636 | // Persist --api-key for custom providers so subsequent runs don't | |
| 618 | 637 | // need to pass it again. | |
| 619 | - | if !isBuiltinProvider(provName) && args.APIKey != "" { | |
| 638 | + | if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" { | |
| 620 | 639 | if store := AuthStoreFor(); store != nil { | |
| 621 | 640 | _ = store.SetAPIKey(provName, args.APIKey) | |
| 622 | 641 | } | |
| @@ -707,6 +726,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 707 | 726 | model = defaultModelForProvider(provName) | |
| 708 | 727 | } | |
| 709 | 728 | } | |
| 729 | + | if isDiscoverableCustomProvider(provName) { | |
| 730 | + | if _, ferr := provider.FindModel(provName, model); ferr != nil { | |
| 731 | + | // Restore discovery-enabled custom models after restart. Skipped | |
| 732 | + | // when the model is already known to avoid a probe per launch. | |
| 733 | + | // Scoped to the selected provider: other providers' endpoints and | |
| 734 | + | // api-key commands must not run for this launch. | |
| 735 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 736 | + | _ = refreshCustomProviderModel(ctx, provName, apiKeyCommandExecute) | |
| 737 | + | cancel() | |
| 738 | + | } | |
| 739 | + | } | |
| 710 | 740 | resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog) | |
| 711 | 741 | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) { | |
| 712 | 742 | // Local providers are intentionally open-catalogue: any model id the | |
| @@ -820,7 +850,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 820 | 850 | _ = SaveConfig(cfg) | |
| 821 | 851 | } | |
| 822 | 852 | ||
| 823 | - | explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "") | |
| 853 | + | // Discovered custom-provider models inherit the user's models.json | |
| 854 | + | // endpoint, so they count as explicit for --insecure scoping too. | |
| 855 | + | discoveredCustom := resolvedModel.Source == "live" && isDiscoverableCustomProvider(provName) | |
| 856 | + | explicitBaseURL := args.BaseURL != "" || ((resolvedModel.Source == "user" || discoveredCustom) && resolvedModel.BaseURL != "") | |
| 824 | 857 | ||
| 825 | 858 | // If the model defines a base URL (e.g. local ollama) and the | |
| 826 | 859 | // user didn't pass --base-url, use the model's URL. For ollama, | |
| @@ -859,8 +892,21 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 859 | 892 | ||
| 860 | 893 | // If the model has a base URL, credentials are optional (local | |
| 861 | 894 | // models like ollama don't need real API keys). | |
| 862 | - | if resolvedModel.BaseURL != "" && credErr != nil { | |
| 863 | - | cred = "ollama" | |
| 895 | + | credentialOptional := false | |
| 896 | + | if keylessCustom { | |
| 897 | + | if args.BaseURL == "" { | |
| 898 | + | return Resolved{}, fmt.Errorf("custom provider %q has auth none but no base URL", provName) | |
| 899 | + | } | |
| 900 | + | credentialOptional = true | |
| 901 | + | requireCred = false | |
| 902 | + | } else if resolvedModel.BaseURL != "" && credErr != nil { | |
| 903 | + | if customProviderConfig != nil && !isBuiltinProvider(provName) { | |
| 904 | + | // A custom endpoint may be configured for keyless access. Keep the | |
| 905 | + | // credential empty so no placeholder token is ever sent. | |
| 906 | + | credentialOptional = true | |
| 907 | + | } else { | |
| 908 | + | cred = "ollama" | |
| 909 | + | } | |
| 864 | 910 | credErr = nil | |
| 865 | 911 | requireCred = false | |
| 866 | 912 | } | |
| @@ -1013,6 +1059,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 1013 | 1059 | modelCatalog: modelCatalog, | |
| 1014 | 1060 | modelCatalogAuthoritative: args.modelCatalogAuthoritative, | |
| 1015 | 1061 | customProviderConfig: customProviderConfig, | |
| 1062 | + | credentialOptional: credentialOptional, | |
| 1016 | 1063 | Sandbox: sandbox, | |
| 1017 | 1064 | SkillTool: skillTool, | |
| 1018 | 1065 | skillsEnabled: skillsEnabled, | |
| @@ -1133,7 +1180,7 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string { | |||
| 1133 | 1180 | // must check HasCredential() first. | |
| 1134 | 1181 | func (r Resolved) NewClient() provider.Client { | |
| 1135 | 1182 | if !r.HasCredential() { | |
| 1136 | - | panic("NewClient called without credential; check HasCredential first") | |
| 1183 | + | panic("NewClient called without credential or keyless endpoint; check HasCredential first") | |
| 1137 | 1184 | } | |
| 1138 | 1185 | wrap := r.withHTTPClient | |
| 1139 | 1186 | switch r.Provider { | |
M packages/agent/cli.go +48 -145
| @@ -73,6 +73,8 @@ func (h *interactiveExtHooks) iv() *modes.Interactive { | |||
| 73 | 73 | return h.interactive | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | func (h *interactiveExtHooks) SupportsInteractiveTools() bool { return h.iv() != nil } | |
| 77 | + | ||
| 76 | 78 | func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) { | |
| 77 | 79 | if h == nil || iv == nil { | |
| 78 | 80 | return | |
| @@ -272,6 +274,7 @@ func (a *extToolAdapter) Tools() []ExtensionToolInfo { | |||
| 272 | 274 | Description: t.Description, | |
| 273 | 275 | Schema: t.Schema, | |
| 274 | 276 | Deferred: t.Deferred, | |
| 277 | + | Interactive: t.Interactive, | |
| 275 | 278 | } | |
| 276 | 279 | } | |
| 277 | 280 | return out | |
| @@ -284,6 +287,7 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool { | |||
| 284 | 287 | Description: info.Description, | |
| 285 | 288 | Schema: info.Schema, | |
| 286 | 289 | Deferred: info.Deferred, | |
| 290 | + | Interactive: info.Interactive, | |
| 287 | 291 | }) | |
| 288 | 292 | } | |
| 289 | 293 | ||
| @@ -1146,7 +1150,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 1146 | 1150 | } | |
| 1147 | 1151 | }() | |
| 1148 | 1152 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 1149 | - | sess, err := openOrCreateSession(ctx, args, r, ag, version) | |
| 1153 | + | sess, freshSession, err := openOrCreateSessionState(ctx, args, r, ag, version) | |
| 1150 | 1154 | if err != nil { | |
| 1151 | 1155 | return err | |
| 1152 | 1156 | } | |
| @@ -1184,6 +1188,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 1184 | 1188 | persistCompaction = sess.AppendCompaction | |
| 1185 | 1189 | } | |
| 1186 | 1190 | started := time.Now() | |
| 1191 | + | prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr) | |
| 1187 | 1192 | usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) | |
| 1188 | 1193 | elapsed := time.Since(started) | |
| 1189 | 1194 | transcriptStart := start | |
| @@ -1228,7 +1233,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||
| 1228 | 1233 | } | |
| 1229 | 1234 | }() | |
| 1230 | 1235 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 1231 | - | sess, err := openOrCreateSession(ctx, args, r, ag, version) | |
| 1236 | + | sess, freshSession, err := openOrCreateSessionState(ctx, args, r, ag, version) | |
| 1232 | 1237 | if err != nil { | |
| 1233 | 1238 | return err | |
| 1234 | 1239 | } | |
| @@ -1268,6 +1273,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||
| 1268 | 1273 | if sess != nil { | |
| 1269 | 1274 | persistCompaction = sess.AppendCompaction | |
| 1270 | 1275 | } | |
| 1276 | + | prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr) | |
| 1271 | 1277 | recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction) | |
| 1272 | 1278 | transcriptStart := start | |
| 1273 | 1279 | if recovery.Compacted { | |
| @@ -1349,7 +1355,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 1349 | 1355 | } | |
| 1350 | 1356 | }() | |
| 1351 | 1357 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 1352 | - | sess, err := openOrCreateSession(ctx, args, r, ag, version) | |
| 1358 | + | sess, freshSession, err := openOrCreateSessionState(ctx, args, r, ag, version) | |
| 1353 | 1359 | if err != nil { | |
| 1354 | 1360 | return err | |
| 1355 | 1361 | } | |
| @@ -1392,6 +1398,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 1392 | 1398 | if sess != nil { | |
| 1393 | 1399 | persistCompaction = sess.AppendCompaction | |
| 1394 | 1400 | } | |
| 1401 | + | prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr) | |
| 1395 | 1402 | recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) | |
| 1396 | 1403 | transcriptStart := start | |
| 1397 | 1404 | if recovery.Compacted { | |
| @@ -1934,6 +1941,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1934 | 1941 | } | |
| 1935 | 1942 | }) | |
| 1936 | 1943 | ||
| 1944 | + | freshSession := true | |
| 1937 | 1945 | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 1938 | 1946 | var sessionTitlePending bool | |
| 1939 | 1947 | // initialPlanSnapshots is reconstructed from the untrimmed resumed | |
| @@ -1942,7 +1950,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1942 | 1950 | var initialPlanSnapshots map[string]core.PlanUpdate | |
| 1943 | 1951 | if !args.NoSess && ag != nil { | |
| 1944 | 1952 | var sessErr error | |
| 1945 | - | sess, sessErr = openOrCreateSession(ctx, args, r, ag, version) | |
| 1953 | + | sess, freshSession, sessErr = openOrCreateSessionState(ctx, args, r, ag, version) | |
| 1946 | 1954 | if sessErr != nil { | |
| 1947 | 1955 | return sessErr | |
| 1948 | 1956 | } | |
| @@ -2386,6 +2394,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2386 | 2394 | committed = true | |
| 2387 | 2395 | persistMu.Unlock() | |
| 2388 | 2396 | if iv != nil { | |
| 2397 | + | iv.SetPinnedSkillsPending(false) | |
| 2389 | 2398 | iv.RefreshGoal() | |
| 2390 | 2399 | iv.RefreshPlan(candidate.planSnapshots) | |
| 2391 | 2400 | } | |
| @@ -2810,49 +2819,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2810 | 2819 | setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow) | |
| 2811 | 2820 | return nil | |
| 2812 | 2821 | }, | |
| 2813 | - | AuthManager: mgr, | |
| 2814 | - | LlamaCPPConfig: ResolveLlamaCPPConfig, | |
| 2815 | - | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | |
| 2816 | - | BuildAgent: buildAgent, | |
| 2817 | - | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | |
| 2818 | - | BuildAgentFor: buildAgentFor, | |
| 2819 | - | BuildAgentForRescue: buildAgentForRescue, | |
| 2820 | - | LoggedInProviders: func() []string { | |
| 2821 | - | var out []string | |
| 2822 | - | seen := map[string]bool{} | |
| 2823 | - | for _, p := range knownProviders { | |
| 2824 | - | if CredentialAvailable(p) && !seen[p] { | |
| 2825 | - | out = append(out, p) | |
| 2826 | - | seen[p] = true | |
| 2827 | - | } | |
| 2828 | - | } | |
| 2829 | - | // Include custom providers that have credentials stored. | |
| 2830 | - | for p := range provider.CustomProviders() { | |
| 2831 | - | if CredentialAvailable(p) && !seen[p] { | |
| 2832 | - | out = append(out, p) | |
| 2833 | - | seen[p] = true | |
| 2834 | - | } | |
| 2835 | - | } | |
| 2836 | - | // Ollama models are always available (no auth needed). | |
| 2837 | - | if !seen["ollama"] { | |
| 2838 | - | out = append(out, "ollama") | |
| 2839 | - | } | |
| 2840 | - | return out | |
| 2841 | - | }, | |
| 2842 | - | LoadSession: loadSession, | |
| 2843 | - | LoadSessionContext: loadSessionContext, | |
| 2844 | - | ChangeCWD: changeCWD, | |
| 2845 | - | PersistCompactHandoff: persistCompactHandoff, | |
| 2846 | - | CurrentCompactHandoff: currentCompactHandoff, | |
| 2847 | - | PersistGoal: persistGoal, | |
| 2848 | - | PersistGoalRuntime: persistGoalRuntime, | |
| 2849 | - | CurrentGoal: currentGoal, | |
| 2850 | - | CurrentGoalHistory: currentGoalHistory, | |
| 2851 | - | CurrentPlan: currentPlan, | |
| 2852 | - | PersistPlan: persistPlan, | |
| 2853 | - | PersistedPlan: persistedPlan, | |
| 2854 | - | InitialPlanSnapshots: initialPlanSnapshots, | |
| 2855 | - | EnsureMission: ensureMission, | |
| 2822 | + | AuthManager: mgr, | |
| 2823 | + | LlamaCPPConfig: ResolveLlamaCPPConfig, | |
| 2824 | + | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | |
| 2825 | + | RefreshCustomProviderModels: RefreshCustomProviderModels, | |
| 2826 | + | CustomDiscoveryConfigured: CustomDiscoveryConfigured, | |
| 2827 | + | BuildAgent: buildAgent, | |
| 2828 | + | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | |
| 2829 | + | BuildAgentFor: buildAgentFor, | |
| 2830 | + | BuildAgentForRescue: buildAgentForRescue, | |
| 2831 | + | LoggedInProviders: modelPickerProviders, | |
| 2832 | + | LoadSession: loadSession, | |
| 2833 | + | LoadSessionContext: loadSessionContext, | |
| 2834 | + | ChangeCWD: changeCWD, | |
| 2835 | + | PersistCompactHandoff: persistCompactHandoff, | |
| 2836 | + | CurrentCompactHandoff: currentCompactHandoff, | |
| 2837 | + | PersistGoal: persistGoal, | |
| 2838 | + | PersistGoalRuntime: persistGoalRuntime, | |
| 2839 | + | CurrentGoal: currentGoal, | |
| 2840 | + | CurrentGoalHistory: currentGoalHistory, | |
| 2841 | + | CurrentPlan: currentPlan, | |
| 2842 | + | PersistPlan: persistPlan, | |
| 2843 | + | PersistedPlan: persistedPlan, | |
| 2844 | + | InitialPlanSnapshots: initialPlanSnapshots, | |
| 2845 | + | EnsureMission: ensureMission, | |
| 2856 | 2846 | CurrentSessionPath: func() string { | |
| 2857 | 2847 | persistMu.Lock() | |
| 2858 | 2848 | defer persistMu.Unlock() | |
| @@ -2931,23 +2921,18 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2931 | 2921 | _ = MarkChangelogShown(v) | |
| 2932 | 2922 | }, | |
| 2933 | 2923 | SkillSnapshot: func() []*skills.Skill { | |
| 2924 | + | return userSkillSnapshot(args, r.CWD, extMgr.Skills()) | |
| 2925 | + | }, | |
| 2926 | + | LoadSkillPins: func(cwd string) (skills.Pins, error) { | |
| 2934 | 2927 | if args.NoSkill { | |
| 2935 | - | // --no-skill: nothing for the picker to show. | |
| 2936 | - | return nil | |
| 2928 | + | return skills.Pins{}, nil | |
| 2937 | 2929 | } | |
| 2938 | - | // Re-discover so the picker reflects edits made during | |
| 2939 | - | // the session. Cheap; SKILL.md files are small. Filter | |
| 2940 | - | // out built-in skills — they're hidden from user-facing | |
| 2941 | - | // surfaces because they're implementation detail; the | |
| 2942 | - | // model still sees them through the system-prompt | |
| 2943 | - | // manifest and the skill tool. | |
| 2944 | - | userHome, _ := os.UserHomeDir() | |
| 2945 | - | list, _ := skills.Discover(ZutHome(), r.CWD, userHome, args.WithSkills) | |
| 2946 | - | list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills()) | |
| 2947 | - | return skills.VisibleSkills(list) | |
| 2930 | + | return loadSkillPins(cwd) | |
| 2948 | 2931 | }, | |
| 2949 | - | NoYolo: args.NoYolo, | |
| 2950 | - | ConfirmGate: confirmGate, | |
| 2932 | + | ToggleSkillPin: toggleSkillPin, | |
| 2933 | + | PreloadPinnedSkills: freshSession && !args.NoSkill, | |
| 2934 | + | NoYolo: args.NoYolo, | |
| 2935 | + | ConfirmGate: confirmGate, | |
| 2951 | 2936 | PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) { | |
| 2952 | 2937 | if iv != nil { | |
| 2953 | 2938 | iv.Notify("session", "error", message) | |
| @@ -3157,90 +3142,8 @@ func agentSessionsRoot(root string, args Args) string { | |||
| 3157 | 3142 | // openOrCreateSession returns a session for the run. sess may be nil | |
| 3158 | 3143 | // with a nil error if session persistence is disabled. | |
| 3159 | 3144 | func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | |
| 3160 | - | if args.NoSess { | |
| 3161 | - | // Allocate the one logical identity for this non-persisted run during | |
| 3162 | - | // runtime assembly instead of allowing a provider adapter to invent it. | |
| 3163 | - | ag.SessionID() | |
| 3164 | - | return nil, nil | |
| 3165 | - | } | |
| 3166 | - | // Sweep meta-only files left over from older zut versions (and from | |
| 3167 | - | // any session that crashed before its first AppendMessage) for the | |
| 3168 | - | // cwd-scoped picker paths. Explicit UUID lookup identifies candidates | |
| 3169 | - | // from their headers so unrelated transcript corruption does not block | |
| 3170 | - | // a valid resume. | |
| 3171 | - | sessionsRoot := agentSessionsRoot(ZutHome(), args) | |
| 3172 | - | if args.ResumeSessionID == "" { | |
| 3173 | - | core.PruneEmptySessions(sessionsRoot, args.CWD) | |
| 3174 | - | } | |
| 3175 | - | var ( | |
| 3176 | - | s *core.Session | |
| 3177 | - | msgs []provider.Message | |
| 3178 | - | err error | |
| 3179 | - | ) | |
| 3180 | - | switch { | |
| 3181 | - | case args.Session != "": | |
| 3182 | - | s, msgs, err = core.OpenSession(args.Session) | |
| 3183 | - | // An explicit session path may be created on first use; session pickers | |
| 3184 | - | // and resume paths still select existing files only. | |
| 3185 | - | if err != nil && errors.Is(err, os.ErrNotExist) { | |
| 3186 | - | s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version) | |
| 3187 | - | msgs = nil | |
| 3188 | - | } | |
| 3189 | - | case args.Continue: | |
| 3190 | - | latest := core.LatestSession(sessionsRoot, args.CWD) | |
| 3191 | - | if latest != "" { | |
| 3192 | - | s, msgs, err = core.OpenSession(latest) | |
| 3193 | - | } | |
| 3194 | - | case args.Resume: | |
| 3195 | - | if args.ResumeSessionID != "" { | |
| 3196 | - | picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID) | |
| 3197 | - | if lookupErr != nil { | |
| 3198 | - | return nil, lookupErr | |
| 3199 | - | } | |
| 3200 | - | if picked == "" { | |
| 3201 | - | return nil, fmt.Errorf("session %q not found", args.ResumeSessionID) | |
| 3202 | - | } | |
| 3203 | - | s, msgs, err = core.OpenSession(picked) | |
| 3204 | - | if err == nil && s.ID != args.ResumeSessionID { | |
| 3205 | - | closeErr := s.Close() | |
| 3206 | - | return nil, errors.Join( | |
| 3207 | - | fmt.Errorf("session %q changed during resume", args.ResumeSessionID), | |
| 3208 | - | closeErr, | |
| 3209 | - | ) | |
| 3210 | - | } | |
| 3211 | - | break | |
| 3212 | - | } | |
| 3213 | - | picked, perr := pickSession(sessionsRoot, args.CWD) | |
| 3214 | - | if perr != nil { | |
| 3215 | - | return nil, perr | |
| 3216 | - | } | |
| 3217 | - | if picked != "" { | |
| 3218 | - | s, msgs, err = core.OpenSession(picked) | |
| 3219 | - | } | |
| 3220 | - | } | |
| 3221 | - | if err != nil { | |
| 3222 | - | return nil, err | |
| 3223 | - | } | |
| 3224 | - | if s != nil { | |
| 3225 | - | if err := ag.BindSessionID(s.Meta.ID); err != nil { | |
| 3226 | - | return nil, err | |
| 3227 | - | } | |
| 3228 | - | ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset) | |
| 3229 | - | ag.SetMessages(msgs) | |
| 3230 | - | if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil { | |
| 3231 | - | ag.SeedCost(cum) | |
| 3232 | - | ag.SeedLastTurnUsage(last) | |
| 3233 | - | } | |
| 3234 | - | return s, nil | |
| 3235 | - | } | |
| 3236 | - | s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version) | |
| 3237 | - | if err == nil { | |
| 3238 | - | if bindErr := ag.BindSessionID(s.Meta.ID); bindErr != nil { | |
| 3239 | - | return nil, bindErr | |
| 3240 | - | } | |
| 3241 | - | ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset) | |
| 3242 | - | } | |
| 3243 | - | return s, err | |
| 3145 | + | sess, _, err := openOrCreateSessionState(ctx, args, r, ag, version) | |
| 3146 | + | return sess, err | |
| 3244 | 3147 | } | |
| 3245 | 3148 | ||
| 3246 | 3149 | func pickSession(root, cwd string) (string, error) { | |
A packages/agent/copilot_availability_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/provider" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestRefreshCopilotAvailabilityWithoutCredentials(t *testing.T) { | |
| 10 | + | preserveProviderCatalog(t) | |
| 11 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 12 | + | t.Setenv("COPILOT_GITHUB_TOKEN", "") | |
| 13 | + | t.Setenv("GITHUB_COPILOT_TOKEN", "") | |
| 14 | + | provider.SetModelAvailability("github-copilot", []string{}) | |
| 15 | + | refreshCopilotModelAvailability() | |
| 16 | + | if len(provider.ModelsForProvider("github-copilot")) == 0 { | |
| 17 | + | t.Fatal("account restriction was retained after credentials disappeared") | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestCopilotDefaultUsesSonnet5(t *testing.T) { | |
| 22 | + | id := defaultModelForProvider("github-copilot") | |
| 23 | + | if id != "claude-sonnet-5" { | |
| 24 | + | t.Fatalf("Copilot default = %q, want claude-sonnet-5", id) | |
| 25 | + | } | |
| 26 | + | if _, err := provider.FindModel("github-copilot", id); err != nil { | |
| 27 | + | t.Fatalf("Copilot default missing from catalog: %v", err) | |
| 28 | + | } | |
| 29 | + | } | |
A packages/agent/customdiscovery.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "net/http" | |
| 7 | + | "os" | |
| 8 | + | "sort" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zut/packages/provider" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // discoverableCustomProviders returns the models.json providers that opted | |
| 16 | + | // into live discovery, sorted for deterministic refresh order. | |
| 17 | + | func discoverableCustomProviders() []string { | |
| 18 | + | var out []string | |
| 19 | + | for name := range provider.CustomProviders() { | |
| 20 | + | if isDiscoverableCustomProvider(name) { | |
| 21 | + | out = append(out, name) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | sort.Strings(out) | |
| 25 | + | return out | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // isDiscoverableCustomProvider reports whether name is a custom provider | |
| 29 | + | // with live discovery enabled. Its catalog entries are transient, so config | |
| 30 | + | // repair must not treat a missing model as stale. | |
| 31 | + | func isDiscoverableCustomProvider(name string) bool { | |
| 32 | + | cfg, ok := provider.CustomProviders()[name] | |
| 33 | + | return ok && cfg.Discover && strings.TrimSpace(cfg.BaseURL) != "" && !isBuiltinProvider(name) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // CustomDiscoveryConfigured reports whether any custom provider has live | |
| 37 | + | // discovery enabled. It reads only loaded configuration, never the network. | |
| 38 | + | func CustomDiscoveryConfigured() bool { | |
| 39 | + | return len(discoverableCustomProviders()) > 0 | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // customProviderKey resolves the optional bearer key for a custom provider. | |
| 43 | + | // A missing key is not an error because local servers commonly accept | |
| 44 | + | // unauthenticated requests. skip is true when the only key is command-backed | |
| 45 | + | // and mode forbids running the command. A provider configured with | |
| 46 | + | // auth "none" never consults the environment, auth.json, or key commands. | |
| 47 | + | func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) { | |
| 48 | + | if cfg, ok := provider.CustomProviders()[name]; ok && cfg.NoAuth && !isBuiltinProvider(name) { | |
| 49 | + | return "", false, nil | |
| 50 | + | } | |
| 51 | + | if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" { | |
| 52 | + | return v, false, nil | |
| 53 | + | } | |
| 54 | + | creds, err := AuthStoreFor().Load() | |
| 55 | + | if err != nil { | |
| 56 | + | return "", false, err | |
| 57 | + | } | |
| 58 | + | stored, ok := creds.AdditionalAPIKeyCreds[name] | |
| 59 | + | if !ok { | |
| 60 | + | return "", false, nil | |
| 61 | + | } | |
| 62 | + | if stored.APIKey == "" && stored.APIKeyCommand != nil && mode == apiKeyCommandSkip { | |
| 63 | + | return "", true, nil | |
| 64 | + | } | |
| 65 | + | key, _, err = resolveStoredAPIKey(ctx, name, stored, mode) | |
| 66 | + | return key, false, err | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // customDiscoveryHTTPClient keeps normal TLS verification for discovery, | |
| 70 | + | // independent of the inference-only insecure setting, and honors proxy envs. | |
| 71 | + | func customDiscoveryHTTPClient() *http.Client { | |
| 72 | + | return provider.NewHTTPClient(false) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // RefreshCustomProviderModels lists models from every discovery-enabled | |
| 76 | + | // custom provider and merges them into the transient managed catalog. | |
| 77 | + | // A failing provider keeps its previous snapshot; errors are joined after | |
| 78 | + | // all providers were attempted. It may run a stored api-key command, so | |
| 79 | + | // only call it for an explicit user action or model launch. | |
| 80 | + | func RefreshCustomProviderModels(ctx context.Context) error { | |
| 81 | + | ctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 82 | + | defer cancel() | |
| 83 | + | return refreshCustomProviderModels(ctx, apiKeyCommandExecute) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // refreshCustomProvidersBackground is the startup variant: it never runs an | |
| 87 | + | // api-key command and skips providers whose only key is command-backed. | |
| 88 | + | func refreshCustomProvidersBackground() { | |
| 89 | + | if !CustomDiscoveryConfigured() { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 93 | + | defer cancel() | |
| 94 | + | _ = refreshCustomProviderModels(ctx, apiKeyCommandSkip) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func refreshCustomProviderModels(ctx context.Context, mode apiKeyCommandMode) error { | |
| 98 | + | var errs []error | |
| 99 | + | var client *http.Client | |
| 100 | + | for _, name := range discoverableCustomProviders() { | |
| 101 | + | if client == nil { | |
| 102 | + | client = customDiscoveryHTTPClient() | |
| 103 | + | } | |
| 104 | + | if err := refreshCustomProviderModelWithClient(ctx, client, name, mode); err != nil { | |
| 105 | + | errs = append(errs, err) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return errors.Join(errs...) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // refreshCustomProviderModel refreshes exactly one discovery-enabled provider. | |
| 112 | + | // Other providers' endpoints and key commands are never touched. A provider | |
| 113 | + | // that did not opt in is a no-op. | |
| 114 | + | func refreshCustomProviderModel(ctx context.Context, name string, mode apiKeyCommandMode) error { | |
| 115 | + | if !isDiscoverableCustomProvider(name) { | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | return refreshCustomProviderModelWithClient(ctx, customDiscoveryHTTPClient(), name, mode) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func refreshCustomProviderModelWithClient(ctx context.Context, client *http.Client, name string, mode apiKeyCommandMode) error { | |
| 122 | + | cfg := provider.CustomProviders()[name] | |
| 123 | + | key, skip, err := customProviderKey(ctx, name, mode) | |
| 124 | + | if err != nil || skip { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | models, err := provider.DiscoverCustomProviderWithClient(ctx, client, name, cfg.BaseURL, key) | |
| 128 | + | if err != nil { | |
| 129 | + | return err // keep the previous snapshot | |
| 130 | + | } | |
| 131 | + | provider.SetManagedModelsForProvider(name, models) | |
| 132 | + | return nil | |
| 133 | + | } | |
A packages/agent/customdiscovery_test.go +486 -0
| @@ -0,0 +1,486 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "slices" | |
| 12 | + | "strings" | |
| 13 | + | "sync/atomic" | |
| 14 | + | "testing" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/zut/packages/provider" | |
| 17 | + | "github.com/bnema/zut/packages/provider/auth" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // loadTestModelsJSON installs a models.json under ZUT_HOME and restores the | |
| 21 | + | // provider catalog and custom-provider registry when the test ends. | |
| 22 | + | func loadTestModelsJSON(t *testing.T, body string) { | |
| 23 | + | t.Helper() | |
| 24 | + | preserveProviderCatalog(t) | |
| 25 | + | if err := os.WriteFile(UserModelsPath(), []byte(body), 0o644); err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | provider.SetManagedModelsForProvider("m4", nil) | |
| 29 | + | provider.SetManagedModelsForProvider("m5", nil) | |
| 30 | + | LoadUserModels() | |
| 31 | + | t.Cleanup(func() { | |
| 32 | + | _ = os.WriteFile(UserModelsPath(), []byte(`{"providers":{}}`), 0o644) | |
| 33 | + | LoadUserModels() | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func newModelsServer(t *testing.T, requests *atomic.Int32, wantAuth string, ids ...string) *httptest.Server { | |
| 38 | + | t.Helper() | |
| 39 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 40 | + | requests.Add(1) | |
| 41 | + | if r.URL.Path != "/v1/models" { | |
| 42 | + | t.Errorf("unexpected path %s", r.URL.Path) | |
| 43 | + | } | |
| 44 | + | if got := r.Header.Get("Authorization"); got != wantAuth { | |
| 45 | + | t.Errorf("Authorization = %q, want %q", got, wantAuth) | |
| 46 | + | } | |
| 47 | + | var data []map[string]string | |
| 48 | + | for _, id := range ids { | |
| 49 | + | data = append(data, map[string]string{"id": id}) | |
| 50 | + | } | |
| 51 | + | _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data}) | |
| 52 | + | })) | |
| 53 | + | t.Cleanup(server.Close) | |
| 54 | + | return server | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestCustomDiscoveryKeepsTLSVerificationWithInsecureInference(t *testing.T) { | |
| 58 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 59 | + | if err := SaveConfig(Config{Insecure: true}); err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | client := customDiscoveryHTTPClient() | |
| 63 | + | if transport, ok := client.Transport.(*http.Transport); ok && transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify { | |
| 64 | + | t.Fatal("discovery inherited insecure inference TLS") | |
| 65 | + | } | |
| 66 | + | server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 67 | + | _, _ = w.Write([]byte(`{"data":[{"id":"test-model"}]}`)) | |
| 68 | + | })) | |
| 69 | + | defer server.Close() | |
| 70 | + | if _, err := provider.DiscoverCustomProviderWithClient(context.Background(), client, "test-provider", server.URL, ""); err == nil { | |
| 71 | + | t.Fatal("discovery trusted a self-signed server") | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestRefreshCustomProviderModelsIsOptIn(t *testing.T) { | |
| 76 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 77 | + | t.Setenv("M4_API_KEY", "") | |
| 78 | + | var requests atomic.Int32 | |
| 79 | + | server := newModelsServer(t, &requests, "", "qwen3-coder") | |
| 80 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers": { | |
| 81 | + | "m4": {"baseUrl": %q, "discover": true}, | |
| 82 | + | "m5": {"baseUrl": %q, "models": [{"id": "pinned"}]} | |
| 83 | + | }}`, server.URL+"/v1", server.URL+"/v1")) | |
| 84 | + | ||
| 85 | + | if !CustomDiscoveryConfigured() { | |
| 86 | + | t.Fatal("discovery not reported as configured") | |
| 87 | + | } | |
| 88 | + | if got := discoverableCustomProviders(); len(got) != 1 || got[0] != "m4" { | |
| 89 | + | t.Fatalf("discoverable = %v", got) | |
| 90 | + | } | |
| 91 | + | if err := RefreshCustomProviderModels(context.Background()); err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | if requests.Load() != 1 { | |
| 95 | + | t.Fatalf("requests = %d, want 1 (only m4)", requests.Load()) | |
| 96 | + | } | |
| 97 | + | m, err := provider.FindModel("m4", "qwen3-coder") | |
| 98 | + | if err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | if m.Source != "live" || m.BaseURL != server.URL+"/v1" || m.ContextWindow <= 0 || m.MaxOutput <= 0 { | |
| 102 | + | t.Fatalf("model = %+v", m) | |
| 103 | + | } | |
| 104 | + | if _, err := provider.FindModel("m5", "qwen3-coder"); err == nil { | |
| 105 | + | t.Fatal("m5 gained discovered models without opting in") | |
| 106 | + | } | |
| 107 | + | if _, err := provider.FindModel("m5", "pinned"); err != nil { | |
| 108 | + | t.Fatal("m5 user model lost") | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestDiscoveryDefaultsOffNoRequestsAtStartup(t *testing.T) { | |
| 113 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 114 | + | var requests atomic.Int32 | |
| 115 | + | server := newModelsServer(t, &requests, "") | |
| 116 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m5":{"baseUrl":%q,"models":[{"id":"pinned"}]}}}`, server.URL+"/v1")) | |
| 117 | + | refreshCustomProvidersBackground() | |
| 118 | + | if requests.Load() != 0 || CustomDiscoveryConfigured() { | |
| 119 | + | t.Fatalf("discovery ran without opt-in (requests=%d)", requests.Load()) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestRefreshCustomProviderModelsUsesStoredKeyAndKeepsUserOverrides(t *testing.T) { | |
| 124 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 125 | + | t.Setenv("M4_API_KEY", "") | |
| 126 | + | var requests atomic.Int32 | |
| 127 | + | server := newModelsServer(t, &requests, "Bearer dummy", "qwen3-coder", "big-model") | |
| 128 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers": { | |
| 129 | + | "m4": {"baseUrl": %q, "discover": true, "models": [{"id": "big-model", "contextWindow": 200000, "maxTokens": 16000}]} | |
| 130 | + | }}`, server.URL+"/v1")) | |
| 131 | + | if err := AuthStoreFor().SetAPIKey("m4", "dummy"); err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | if err := RefreshCustomProviderModels(context.Background()); err != nil { | |
| 135 | + | t.Fatal(err) | |
| 136 | + | } | |
| 137 | + | if requests.Load() != 1 { | |
| 138 | + | t.Fatalf("requests = %d", requests.Load()) | |
| 139 | + | } | |
| 140 | + | big, err := provider.FindModel("m4", "big-model") | |
| 141 | + | if err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | if big.Source != "user" || big.ContextWindow != 200000 || big.MaxOutput != 16000 { | |
| 145 | + | t.Fatalf("models.json override lost: %+v", big) | |
| 146 | + | } | |
| 147 | + | if _, err := provider.FindModel("m4", "qwen3-coder"); err != nil { | |
| 148 | + | t.Fatal("discovered model missing") | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestRefreshCustomProviderModelsExplicitKeylessIgnoresStoredKey(t *testing.T) { | |
| 153 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 154 | + | t.Setenv("M4_API_KEY", "env-key") | |
| 155 | + | var requests atomic.Int32 | |
| 156 | + | server := newModelsServer(t, &requests, "", "qwen3-coder") | |
| 157 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m4":{"baseUrl":%q,"discover":true,"auth":"none"}}}`, server.URL+"/v1")) | |
| 158 | + | if err := AuthStoreFor().SetAPIKey("m4", "stored-key"); err != nil { | |
| 159 | + | t.Fatal(err) | |
| 160 | + | } | |
| 161 | + | if err := RefreshCustomProviderModels(context.Background()); err != nil { | |
| 162 | + | t.Fatal(err) | |
| 163 | + | } | |
| 164 | + | if requests.Load() != 1 { | |
| 165 | + | t.Fatalf("requests = %d, want 1", requests.Load()) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) { | |
| 170 | + | home := t.TempDir() | |
| 171 | + | t.Setenv("ZUT_HOME", home) | |
| 172 | + | t.Setenv("M4_API_KEY", "") | |
| 173 | + | var requests atomic.Int32 | |
| 174 | + | server := newModelsServer(t, &requests, "", "m") | |
| 175 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server.URL)) | |
| 176 | + | creds := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{ | |
| 177 | + | "m4": {APIKeyCommand: &auth.APIKeyCommand{Program: filepath.Join(home, "does-not-exist")}}, | |
| 178 | + | }} | |
| 179 | + | encoded, err := json.Marshal(creds) | |
| 180 | + | if err != nil { | |
| 181 | + | t.Fatal(err) | |
| 182 | + | } | |
| 183 | + | if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil { | |
| 184 | + | t.Fatal(err) | |
| 185 | + | } | |
| 186 | + | if err := refreshCustomProviderModels(context.Background(), apiKeyCommandSkip); err != nil { | |
| 187 | + | t.Fatal(err) | |
| 188 | + | } | |
| 189 | + | if requests.Load() != 0 { | |
| 190 | + | t.Fatal("background refresh probed a provider whose key needs a command") | |
| 191 | + | } | |
| 192 | + | err = RefreshCustomProviderModels(context.Background()) | |
| 193 | + | if err == nil || !strings.Contains(err.Error(), "m4") { | |
| 194 | + | t.Fatalf("command failure not reported: %v", err) | |
| 195 | + | } | |
| 196 | + | if requests.Load() != 0 { | |
| 197 | + | t.Fatal("refresh probed after key command failed") | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestRefreshCustomProviderModelsKeepsSnapshotOnFailure(t *testing.T) { | |
| 202 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 203 | + | t.Setenv("M4_API_KEY", "") | |
| 204 | + | t.Setenv("M5_API_KEY", "") | |
| 205 | + | var requests atomic.Int32 | |
| 206 | + | good := newModelsServer(t, &requests, "", "alive") | |
| 207 | + | failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 208 | + | http.Error(w, "nope", http.StatusServiceUnavailable) | |
| 209 | + | })) | |
| 210 | + | defer failing.Close() | |
| 211 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers": { | |
| 212 | + | "m4": {"baseUrl": %q, "discover": true}, | |
| 213 | + | "m5": {"baseUrl": %q, "discover": true} | |
| 214 | + | }}`, good.URL, failing.URL)) | |
| 215 | + | provider.SetManagedModelsForProvider("m5", []provider.Model{{Provider: "m5", ID: "previous", Source: "live"}}) | |
| 216 | + | ||
| 217 | + | err := RefreshCustomProviderModels(context.Background()) | |
| 218 | + | if err == nil || !strings.Contains(err.Error(), "m5 discovery HTTP 503") { | |
| 219 | + | t.Fatalf("err = %v", err) | |
| 220 | + | } | |
| 221 | + | if _, err := provider.FindModel("m4", "alive"); err != nil { | |
| 222 | + | t.Fatal("healthy provider not refreshed when a sibling failed") | |
| 223 | + | } | |
| 224 | + | if _, err := provider.FindModel("m5", "previous"); err != nil { | |
| 225 | + | t.Fatal("failed refresh dropped the previous snapshot") | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestValidateAndRepairConfig_PreservesDiscoverableCustomModel(t *testing.T) { | |
| 230 | + | home := t.TempDir() | |
| 231 | + | t.Setenv("ZUT_HOME", home) | |
| 232 | + | loadTestModelsJSON(t, `{"providers": { | |
| 233 | + | "m4": {"baseUrl": "http://127.0.0.1:1/v1", "discover": true}, | |
| 234 | + | "m5": {"baseUrl": "http://127.0.0.1:1/v1", "models": [{"id": "pinned"}]} | |
| 235 | + | }}`) | |
| 236 | + | write := func(c Config) { | |
| 237 | + | t.Helper() | |
| 238 | + | b, _ := json.Marshal(c) | |
| 239 | + | if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil { | |
| 240 | + | t.Fatal(err) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | write(Config{Provider: "m4", Model: "qwen3-coder"}) | |
| 244 | + | ValidateAndRepairConfig() | |
| 245 | + | out, _ := LoadConfig() | |
| 246 | + | if out.Provider != "m4" || out.Model != "qwen3-coder" { | |
| 247 | + | t.Fatalf("discoverable custom model repaired away: %+v", out) | |
| 248 | + | } | |
| 249 | + | write(Config{Provider: "m5", Model: "gone"}) | |
| 250 | + | ValidateAndRepairConfig() | |
| 251 | + | out, _ = LoadConfig() | |
| 252 | + | if out.Provider != "m5" || out.Model != "pinned" { | |
| 253 | + | t.Fatalf("non-discoverable custom model not repaired: %+v", out) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestResolveRestoresDiscoveredCustomModelAfterRestart(t *testing.T) { | |
| 258 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 259 | + | t.Setenv("M4_API_KEY", "") | |
| 260 | + | var requests atomic.Int32 | |
| 261 | + | server := newModelsServer(t, &requests, "", "qwen3-coder") | |
| 262 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server.URL+"/v1")) | |
| 263 | + | ||
| 264 | + | r, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true) | |
| 265 | + | if err != nil { | |
| 266 | + | t.Fatal(err) | |
| 267 | + | } | |
| 268 | + | if requests.Load() != 1 { | |
| 269 | + | t.Fatalf("requests = %d, want a restore probe", requests.Load()) | |
| 270 | + | } | |
| 271 | + | if r.Provider != "m4" || r.Model != "qwen3-coder" || r.BaseURL != server.URL+"/v1" || r.MaxOutput <= 0 { | |
| 272 | + | t.Fatalf("resolved = %+v", r) | |
| 273 | + | } | |
| 274 | + | if !r.HasCredential() || r.Credential != "" { | |
| 275 | + | t.Fatalf("keyless discovered provider should be usable without a placeholder: %q", r.Credential) | |
| 276 | + | } | |
| 277 | + | if _, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true); err != nil { | |
| 278 | + | t.Fatal(err) | |
| 279 | + | } | |
| 280 | + | if requests.Load() != 1 { | |
| 281 | + | t.Fatalf("requests = %d, want no extra probe", requests.Load()) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func streamOnce(t *testing.T, r Resolved, model string) { | |
| 286 | + | t.Helper() | |
| 287 | + | events, err := r.NewClient().Stream(context.Background(), provider.Request{ | |
| 288 | + | Model: model, Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}}, | |
| 289 | + | }) | |
| 290 | + | if err != nil { | |
| 291 | + | t.Fatal(err) | |
| 292 | + | } | |
| 293 | + | for range events { | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestCustomProviderKeylessOmitsAuthorization(t *testing.T) { | |
| 298 | + | for _, api := range []string{"openai", "openai-responses", "anthropic"} { | |
| 299 | + | t.Run(api, func(t *testing.T) { | |
| 300 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 301 | + | t.Setenv("UNSLOTH_API_KEY", "") | |
| 302 | + | got := make(chan http.Header, 1) | |
| 303 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 304 | + | got <- r.Header.Clone() | |
| 305 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 306 | + | })) | |
| 307 | + | t.Cleanup(srv.Close) | |
| 308 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"`+api+`","models":[{"id":"qista"}]}}}`) | |
| 309 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true) | |
| 310 | + | if err != nil { | |
| 311 | + | t.Fatal(err) | |
| 312 | + | } | |
| 313 | + | if r.Credential != "" || !r.HasCredential() { | |
| 314 | + | t.Fatalf("credential = %q usable=%v", r.Credential, r.HasCredential()) | |
| 315 | + | } | |
| 316 | + | streamOnce(t, r, "qista") | |
| 317 | + | h := <-got | |
| 318 | + | if _, ok := h["Authorization"]; ok { | |
| 319 | + | t.Fatal("Authorization sent") | |
| 320 | + | } | |
| 321 | + | if _, ok := h["X-Api-Key"]; ok { | |
| 322 | + | t.Fatal("X-Api-Key sent") | |
| 323 | + | } | |
| 324 | + | }) | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func TestCustomProviderExplicitKeylessIgnoresInheritedKeys(t *testing.T) { | |
| 329 | + | for _, api := range []string{"openai", "openai-responses", "anthropic"} { | |
| 330 | + | t.Run(api, func(t *testing.T) { | |
| 331 | + | home := t.TempDir() | |
| 332 | + | t.Setenv("ZUT_HOME", home) | |
| 333 | + | t.Setenv("UNSLOTH_API_KEY", "env-key") | |
| 334 | + | got := make(chan http.Header, 1) | |
| 335 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 336 | + | got <- r.Header.Clone() | |
| 337 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 338 | + | })) | |
| 339 | + | t.Cleanup(srv.Close) | |
| 340 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"`+api+`","auth":"none","models":[{"id":"qista"}]}}}`) | |
| 341 | + | // A stored command-backed key must never run for a keyless provider. | |
| 342 | + | marker := filepath.Join(home, "ran") | |
| 343 | + | creds := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{ | |
| 344 | + | "unsloth": {APIKeyCommand: &auth.APIKeyCommand{Program: "/bin/sh", Args: []string{"-c", "touch " + marker + "; echo secret"}}}, | |
| 345 | + | }} | |
| 346 | + | encoded, _ := json.Marshal(creds) | |
| 347 | + | if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil { | |
| 348 | + | t.Fatal(err) | |
| 349 | + | } | |
| 350 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", APIKey: "cli-key", NoSkill: true}, true) | |
| 351 | + | if err != nil { | |
| 352 | + | t.Fatal(err) | |
| 353 | + | } | |
| 354 | + | if r.Credential != "" || !r.HasCredential() { | |
| 355 | + | t.Fatalf("keyless resolved credential = %q, usable = %v", r.Credential, r.HasCredential()) | |
| 356 | + | } | |
| 357 | + | streamOnce(t, r, "qista") | |
| 358 | + | h := <-got | |
| 359 | + | if _, ok := h["Authorization"]; ok { | |
| 360 | + | t.Fatal("Authorization sent") | |
| 361 | + | } | |
| 362 | + | if _, ok := h["X-Api-Key"]; ok { | |
| 363 | + | t.Fatal("X-Api-Key sent") | |
| 364 | + | } | |
| 365 | + | if _, err := os.Stat(marker); err == nil { | |
| 366 | + | t.Fatal("stored key command executed for explicit keyless provider") | |
| 367 | + | } | |
| 368 | + | stored, err := AuthStoreFor().Load() | |
| 369 | + | if err != nil || stored.AdditionalAPIKeyCreds["unsloth"].APIKeyCommand == nil { | |
| 370 | + | t.Fatalf("stored credential changed: %+v, %v", stored.AdditionalAPIKeyCreds["unsloth"], err) | |
| 371 | + | } | |
| 372 | + | }) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func TestCustomProviderWithKeyStillSendsAuthorization(t *testing.T) { | |
| 377 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 378 | + | t.Setenv("UNSLOTH_API_KEY", "") | |
| 379 | + | got := make(chan string, 1) | |
| 380 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 381 | + | got <- r.Header.Get("Authorization") | |
| 382 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 383 | + | })) | |
| 384 | + | t.Cleanup(srv.Close) | |
| 385 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |
| 386 | + | if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil { | |
| 387 | + | t.Fatal(err) | |
| 388 | + | } | |
| 389 | + | r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true) | |
| 390 | + | if err != nil { | |
| 391 | + | t.Fatal(err) | |
| 392 | + | } | |
| 393 | + | streamOnce(t, r, "qista") | |
| 394 | + | if a := <-got; a != "Bearer stored-key" { | |
| 395 | + | t.Fatalf("Authorization = %q", a) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func TestBuiltinProviderStillRequiresCredentialAndKeepsPlaceholderRules(t *testing.T) { | |
| 400 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 401 | + | t.Setenv("ANTHROPIC_API_KEY", "") | |
| 402 | + | t.Setenv("ANTHROPIC_OAUTH_TOKEN", "") | |
| 403 | + | // A custom provider named like a built-in must not become keyless. | |
| 404 | + | loadTestModelsJSON(t, `{"providers":{"anthropic":{"baseUrl":"http://127.0.0.1:9/v1","auth":"none","models":[{"id":"claude-sonnet-5"}]}}}`) | |
| 405 | + | r, err := Resolve(Args{Provider: "anthropic", Model: "claude-sonnet-5", NoSkill: true}, false) | |
| 406 | + | if err != nil { | |
| 407 | + | t.Fatal(err) | |
| 408 | + | } | |
| 409 | + | if r.credentialOptional { | |
| 410 | + | t.Fatal("built-in provider treated as keyless custom endpoint") | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func TestModelPickerProvidersIncludeKeylessCustomEndpoint(t *testing.T) { | |
| 415 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 416 | + | t.Setenv("UNSLOTH_API_KEY", "") | |
| 417 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"http://127.0.0.1:8888/v1","api":"openai","models":[{"id":"qista"}]}}}`) | |
| 418 | + | if CredentialAvailable("unsloth") { | |
| 419 | + | t.Fatal("test requires no credential") | |
| 420 | + | } | |
| 421 | + | if !slices.Contains(modelPickerProviders(), "unsloth") { | |
| 422 | + | t.Fatal("keyless custom endpoint missing from model picker") | |
| 423 | + | } | |
| 424 | + | loadTestModelsJSON(t, `{"providers":{"unsloth":{"models":[{"id":"qista","baseUrl":"http://127.0.0.1:8888/v1"}]}}}`) | |
| 425 | + | if !slices.Contains(modelPickerProviders(), "unsloth") { | |
| 426 | + | t.Fatal("model-level keyless endpoint missing from model picker") | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func TestResolveUnknownModelRefreshesOnlySelectedCustomProvider(t *testing.T) { | |
| 431 | + | home := t.TempDir() | |
| 432 | + | t.Setenv("ZUT_HOME", home) | |
| 433 | + | t.Setenv("M4_API_KEY", "") | |
| 434 | + | t.Setenv("M5_API_KEY", "") | |
| 435 | + | var m4Requests, m5Requests atomic.Int32 | |
| 436 | + | m4 := newModelsServer(t, &m4Requests, "", "qwen3-coder") | |
| 437 | + | m5 := newModelsServer(t, &m5Requests, "", "other") | |
| 438 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers": { | |
| 439 | + | "m4": {"baseUrl": %q, "discover": true}, | |
| 440 | + | "m5": {"baseUrl": %q, "discover": true} | |
| 441 | + | }}`, m4.URL+"/v1", m5.URL+"/v1")) | |
| 442 | + | // m5 has a command-backed key; resolving m4 must never run it. | |
| 443 | + | marker := filepath.Join(home, "m5-command-ran") | |
| 444 | + | creds := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{ | |
| 445 | + | "m5": {APIKeyCommand: &auth.APIKeyCommand{Program: "/bin/sh", Args: []string{"-c", "touch " + marker + "; echo secret"}}}, | |
| 446 | + | }} | |
| 447 | + | encoded, _ := json.Marshal(creds) | |
| 448 | + | if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil { | |
| 449 | + | t.Fatal(err) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | if _, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true); err != nil { | |
| 453 | + | t.Fatal(err) | |
| 454 | + | } | |
| 455 | + | if m4Requests.Load() != 1 { | |
| 456 | + | t.Fatalf("selected provider requests = %d, want 1", m4Requests.Load()) | |
| 457 | + | } | |
| 458 | + | if m5Requests.Load() != 0 { | |
| 459 | + | t.Fatal("unrelated provider endpoint was contacted") | |
| 460 | + | } | |
| 461 | + | if _, err := os.Stat(marker); err == nil { | |
| 462 | + | t.Fatal("unrelated provider api-key command executed") | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | func TestRefreshSingleCustomProviderKeepsSnapshotOnFailure(t *testing.T) { | |
| 467 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 468 | + | t.Setenv("M4_API_KEY", "") | |
| 469 | + | failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 470 | + | http.Error(w, "nope", http.StatusServiceUnavailable) | |
| 471 | + | })) | |
| 472 | + | defer failing.Close() | |
| 473 | + | loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m4":{"baseUrl":%q,"discover":true},"m6":{"baseUrl":%q}}}`, failing.URL, failing.URL)) | |
| 474 | + | provider.SetManagedModelsForProvider("m4", []provider.Model{{Provider: "m4", ID: "previous", Source: "live"}}) | |
| 475 | + | ||
| 476 | + | if err := refreshCustomProviderModel(context.Background(), "m4", apiKeyCommandExecute); err == nil { | |
| 477 | + | t.Fatal("expected refresh failure") | |
| 478 | + | } | |
| 479 | + | if _, err := provider.FindModel("m4", "previous"); err != nil { | |
| 480 | + | t.Fatal("failed scoped refresh dropped the previous snapshot") | |
| 481 | + | } | |
| 482 | + | // A provider that did not opt in is never contacted or changed. | |
| 483 | + | if err := refreshCustomProviderModel(context.Background(), "m6", apiKeyCommandExecute); err != nil { | |
| 484 | + | t.Fatalf("non-discoverable provider refreshed: %v", err) | |
| 485 | + | } | |
| 486 | + | } | |
M packages/agent/ext/ext.go +88 -9
| @@ -33,6 +33,7 @@ package ext | |||
| 33 | 33 | ||
| 34 | 34 | import ( | |
| 35 | 35 | "bufio" | |
| 36 | + | "context" | |
| 36 | 37 | "encoding/base64" | |
| 37 | 38 | "encoding/json" | |
| 38 | 39 | "fmt" | |
| @@ -57,6 +58,13 @@ type CommandHandler func(args string) Response | |||
| 57 | 58 | // ToolResult describing what zut should send back to the model. | |
| 58 | 59 | type ToolHandler func(args json.RawMessage) ToolResult | |
| 59 | 60 | ||
| 61 | + | // InteractiveToolHandler is invoked for tools registered with | |
| 62 | + | // InteractiveTool. ctx is cancelled when the host abandons the call | |
| 63 | + | // (tool_cancel), on shutdown, or when the host connection ends. The | |
| 64 | + | // handler must release panels and other resources when ctx ends. The | |
| 65 | + | // SDK ignores a result returned after cancellation. | |
| 66 | + | type InteractiveToolHandler func(ctx context.Context, args json.RawMessage) ToolResult | |
| 67 | + | ||
| 60 | 68 | // EventHandler is called for each lifecycle event the extension | |
| 61 | 69 | // subscribed to via Subscribe. The handler is invoked synchronously | |
| 62 | 70 | // on the read goroutine; keep it quick or hand off to your own | |
| @@ -283,8 +291,9 @@ type Extension struct { | |||
| 283 | 291 | mu sync.Mutex | |
| 284 | 292 | commands map[string]CommandHandler | |
| 285 | 293 | descriptions []descTuple // ordered so register frames arrive in registration order | |
| 286 | - | tools map[string]ToolHandler | |
| 287 | - | toolDefs []toolDef // ordered so register frames arrive in registration order | |
| 294 | + | tools map[string]InteractiveToolHandler | |
| 295 | + | toolCancels map[string]*toolCall // in-flight tool_call id -> cancel handle | |
| 296 | + | toolDefs []toolDef // ordered so register frames arrive in registration order | |
| 288 | 297 | eventHandlers map[string]EventHandler | |
| 289 | 298 | eventNames []string // declared subscription order | |
| 290 | 299 | ||
| @@ -313,6 +322,12 @@ type toolDef struct { | |||
| 313 | 322 | description string | |
| 314 | 323 | schema json.RawMessage | |
| 315 | 324 | deferred bool | |
| 325 | + | interactive bool | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // toolCall is the cancel handle of one in-flight tool invocation. | |
| 329 | + | type toolCall struct { | |
| 330 | + | cancel context.CancelFunc | |
| 316 | 331 | } | |
| 317 | 332 | ||
| 318 | 333 | // HostInfo is what the host (zut) tells us in HelloAck. Useful for | |
| @@ -338,11 +353,12 @@ func New(name, version string) *Extension { | |||
| 338 | 353 | out: os.Stdout, | |
| 339 | 354 | stderr: os.Stderr, | |
| 340 | 355 | commands: map[string]CommandHandler{}, | |
| 341 | - | tools: map[string]ToolHandler{}, | |
| 356 | + | tools: map[string]InteractiveToolHandler{}, | |
| 357 | + | toolCancels: map[string]*toolCall{}, | |
| 342 | 358 | eventHandlers: map[string]EventHandler{}, | |
| 343 | 359 | panelKeys: map[string]func(key, text string){}, | |
| 344 | 360 | panelCloses: map[string]func(){}, | |
| 345 | - | caps: []string{"commands", "tools", "events", "alerts", "panels", "session_state", "status", "widgets"}, | |
| 361 | + | caps: []string{"commands", "tools", "events", "alerts", "panels", "session_state", "status", "widgets", "tool_cancel"}, | |
| 346 | 362 | } | |
| 347 | 363 | } | |
| 348 | 364 | ||
| @@ -437,13 +453,39 @@ func (e *Extension) DeferredTool(name, description string, schema json.RawMessag | |||
| 437 | 453 | e.registerTool(name, description, schema, true, fn) | |
| 438 | 454 | } | |
| 439 | 455 | ||
| 440 | - | func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) { | |
| 456 | + | // InteractiveTool registers a tool that intentionally waits for user input, | |
| 457 | + | // for example by opening a panel and returning once a key arrives. An | |
| 458 | + | // interactive host imposes no reply deadline on it; headless hosts (print, | |
| 459 | + | // JSON, RPC, bot) reject the call with a tool error instead. The handler | |
| 460 | + | // must honor ctx: it is cancelled when the host abandons the call, on | |
| 461 | + | // shutdown, and when the host connection ends. Close any panel the handler | |
| 462 | + | // opened before returning on either path. | |
| 463 | + | func (e *Extension) InteractiveTool(name, description string, schema json.RawMessage, fn InteractiveToolHandler) { | |
| 441 | 464 | e.mu.Lock() | |
| 442 | 465 | e.tools[name] = fn | |
| 466 | + | e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, interactive: true}) | |
| 467 | + | e.mu.Unlock() | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) { | |
| 471 | + | e.mu.Lock() | |
| 472 | + | e.tools[name] = func(_ context.Context, args json.RawMessage) ToolResult { return fn(args) } | |
| 443 | 473 | e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred}) | |
| 444 | 474 | e.mu.Unlock() | |
| 445 | 475 | } | |
| 446 | 476 | ||
| 477 | + | // cancelAllTools cancels every in-flight tool handler context. Used on | |
| 478 | + | // shutdown and host EOF so blocked interactive handlers can clean up. | |
| 479 | + | func (e *Extension) cancelAllTools() { | |
| 480 | + | e.mu.Lock() | |
| 481 | + | calls := e.toolCancels | |
| 482 | + | e.toolCancels = map[string]*toolCall{} | |
| 483 | + | e.mu.Unlock() | |
| 484 | + | for _, call := range calls { | |
| 485 | + | call.cancel() | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 447 | 489 | // On subscribes to a lifecycle event. fn is called for each | |
| 448 | 490 | // notification; the same name can only have one handler (later | |
| 449 | 491 | // registrations replace earlier ones). Recognised names include: | |
| @@ -558,6 +600,7 @@ func (e *Extension) Alert(alert AlertRequest) { | |||
| 558 | 600 | // Run starts the protocol loop. Blocks until stdin closes (zut has | |
| 559 | 601 | // shut us down). Returns the first fatal error, or nil on clean exit. | |
| 560 | 602 | func (e *Extension) Run() error { | |
| 603 | + | defer e.cancelAllTools() | |
| 561 | 604 | scanner := bufio.NewScanner(e.in) | |
| 562 | 605 | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | |
| 563 | 606 | ||
| @@ -628,6 +671,7 @@ func (e *Extension) Run() error { | |||
| 628 | 671 | Description: td.description, | |
| 629 | 672 | Schema: td.schema, | |
| 630 | 673 | Deferred: td.deferred, | |
| 674 | + | Interactive: td.interactive, | |
| 631 | 675 | }) | |
| 632 | 676 | } | |
| 633 | 677 | var intercepts []string | |
| @@ -698,15 +742,49 @@ func (e *Extension) Run() error { | |||
| 698 | 742 | e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name))) | |
| 699 | 743 | continue | |
| 700 | 744 | } | |
| 701 | - | go func(id string, fn ToolHandler, args json.RawMessage) { | |
| 745 | + | // Register the cancel before dispatching so a tool_cancel that | |
| 746 | + | // arrives before the goroutine starts still lands. | |
| 747 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 748 | + | call := &toolCall{cancel: cancel} | |
| 749 | + | e.mu.Lock() | |
| 750 | + | if prev := e.toolCancels[tc.ID]; prev != nil { | |
| 751 | + | prev.cancel() // duplicate id from the host; abandon the older call | |
| 752 | + | } | |
| 753 | + | e.toolCancels[tc.ID] = call | |
| 754 | + | e.mu.Unlock() | |
| 755 | + | go func(id string, fn InteractiveToolHandler, args json.RawMessage) { | |
| 702 | 756 | defer func() { | |
| 703 | - | if r := recover(); r != nil { | |
| 757 | + | cancel() | |
| 758 | + | e.mu.Lock() | |
| 759 | + | if e.toolCancels[id] == call { | |
| 760 | + | delete(e.toolCancels, id) | |
| 761 | + | } | |
| 762 | + | e.mu.Unlock() | |
| 763 | + | }() | |
| 764 | + | defer func() { | |
| 765 | + | if r := recover(); r != nil && ctx.Err() == nil { | |
| 704 | 766 | e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r))) | |
| 705 | 767 | } | |
| 706 | 768 | }() | |
| 707 | - | res := fn(args) | |
| 708 | - | e.respondTool(id, res) | |
| 769 | + | res := fn(ctx, args) | |
| 770 | + | // A result produced after cancellation is stale: the host | |
| 771 | + | // already dropped the pending slot and would ignore it. | |
| 772 | + | if ctx.Err() == nil { | |
| 773 | + | e.respondTool(id, res) | |
| 774 | + | } | |
| 709 | 775 | }(tc.ID, fn, tc.Args) | |
| 776 | + | case "tool_cancel": | |
| 777 | + | var tc extproto.ToolCancelFromHost | |
| 778 | + | if err := json.Unmarshal(line, &tc); err != nil { | |
| 779 | + | continue | |
| 780 | + | } | |
| 781 | + | e.mu.Lock() | |
| 782 | + | call := e.toolCancels[tc.ID] | |
| 783 | + | delete(e.toolCancels, tc.ID) | |
| 784 | + | e.mu.Unlock() | |
| 785 | + | if call != nil { | |
| 786 | + | call.cancel() | |
| 787 | + | } | |
| 710 | 788 | case "event": | |
| 711 | 789 | var ef extproto.EventFromHost | |
| 712 | 790 | if err := json.Unmarshal(line, &ef); err != nil { | |
| @@ -759,6 +837,7 @@ func (e *Extension) Run() error { | |||
| 759 | 837 | go h() | |
| 760 | 838 | } | |
| 761 | 839 | case "shutdown": | |
| 840 | + | e.cancelAllTools() | |
| 762 | 841 | _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"}) | |
| 763 | 842 | return nil | |
| 764 | 843 | default: | |
A packages/agent/ext/interactive_tool_test.go +241 -0
| @@ -0,0 +1,241 @@ | |||
| 1 | + | package ext | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/extproto" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // interactiveHarness registers one interactive tool whose handler blocks on | |
| 13 | + | // ctx or on an explicit answer, and reports when it started and ended. | |
| 14 | + | type interactiveHarness struct { | |
| 15 | + | *extHarness | |
| 16 | + | started chan string // tool_call id | |
| 17 | + | ended chan error // ctx.Err() at handler exit | |
| 18 | + | answers chan string | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func newInteractiveHarness(t *testing.T) *interactiveHarness { | |
| 22 | + | t.Helper() | |
| 23 | + | h := &interactiveHarness{ | |
| 24 | + | extHarness: newHarness("interactive-ext"), | |
| 25 | + | started: make(chan string, 8), | |
| 26 | + | ended: make(chan error, 8), | |
| 27 | + | answers: make(chan string, 8), | |
| 28 | + | } | |
| 29 | + | h.ext.InteractiveTool("ask", "ask the user", json.RawMessage(`{"type":"object"}`), | |
| 30 | + | func(ctx context.Context, args json.RawMessage) ToolResult { | |
| 31 | + | var in struct { | |
| 32 | + | ID string `json:"id"` | |
| 33 | + | } | |
| 34 | + | _ = json.Unmarshal(args, &in) | |
| 35 | + | h.started <- in.ID | |
| 36 | + | select { | |
| 37 | + | case a := <-h.answers: | |
| 38 | + | h.ended <- ctx.Err() | |
| 39 | + | return TextResult(a) | |
| 40 | + | case <-ctx.Done(): | |
| 41 | + | h.ended <- ctx.Err() | |
| 42 | + | return TextErrorResult("cancelled") | |
| 43 | + | } | |
| 44 | + | }) | |
| 45 | + | h.ext.Tool("plain", "plain", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult { | |
| 46 | + | return TextResult("plain-ok") | |
| 47 | + | }) | |
| 48 | + | return h | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (h *interactiveHarness) call(t *testing.T, id string) { | |
| 52 | + | t.Helper() | |
| 53 | + | h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: id, Name: "ask", Args: json.RawMessage(`{"id":"` + id + `"}`)}) | |
| 54 | + | select { | |
| 55 | + | case got := <-h.started: | |
| 56 | + | if got != id { | |
| 57 | + | t.Fatalf("handler started for %q, want %q", got, id) | |
| 58 | + | } | |
| 59 | + | case <-time.After(2 * time.Second): | |
| 60 | + | t.Fatalf("handler for %s did not start", id) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (h *interactiveHarness) awaitEnd(t *testing.T) error { | |
| 65 | + | t.Helper() | |
| 66 | + | select { | |
| 67 | + | case err := <-h.ended: | |
| 68 | + | return err | |
| 69 | + | case <-time.After(2 * time.Second): | |
| 70 | + | t.Fatal("handler did not end") | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // expectNoToolResult drains frames for a short window and fails on any | |
| 76 | + | // tool_result. Used after a cancel to prove late results are suppressed. | |
| 77 | + | func (h *interactiveHarness) expectNoToolResult(t *testing.T) { | |
| 78 | + | t.Helper() | |
| 79 | + | // Force a round trip: an event frame is processed on the read loop after | |
| 80 | + | // any tool_result the handler goroutine could have queued, so once we | |
| 81 | + | // observe the ack for it, an earlier tool_result would already be here. | |
| 82 | + | h.ext.OnPanelKey("probe", func(string, string) { h.ext.Notify("info", "probe") }, nil) | |
| 83 | + | h.sendToExt(t, extproto.PanelKeyFromHost{Type: "panel_key", PanelID: "probe", Key: "rune", Text: "x"}) | |
| 84 | + | for { | |
| 85 | + | f := h.next(t) | |
| 86 | + | switch f.hdr.Type { | |
| 87 | + | case "tool_result": | |
| 88 | + | t.Fatalf("late tool_result emitted after cancellation: %s", f.raw) | |
| 89 | + | case "notify": | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestInteractiveToolRegistersFlagAndCapability(t *testing.T) { | |
| 96 | + | h := newInteractiveHarness(t) | |
| 97 | + | h.startRun(t) | |
| 98 | + | f := h.next(t) | |
| 99 | + | var hello extproto.HelloFromExt | |
| 100 | + | if err := json.Unmarshal(f.raw, &hello); err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | hasCancel := false | |
| 104 | + | for _, c := range hello.Capabilities { | |
| 105 | + | if c == "tool_cancel" { | |
| 106 | + | hasCancel = true | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | if !hasCancel { | |
| 110 | + | t.Fatalf("hello capabilities = %v, want tool_cancel", hello.Capabilities) | |
| 111 | + | } | |
| 112 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion, ZutVersion: "0.0.0-test"}) | |
| 113 | + | seen := map[string]bool{} | |
| 114 | + | for { | |
| 115 | + | f := h.next(t) | |
| 116 | + | if f.hdr.Type == "ready" { | |
| 117 | + | break | |
| 118 | + | } | |
| 119 | + | if f.hdr.Type != "register_tool" { | |
| 120 | + | continue | |
| 121 | + | } | |
| 122 | + | var rt extproto.RegisterToolFromExt | |
| 123 | + | if err := json.Unmarshal(f.raw, &rt); err != nil { | |
| 124 | + | t.Fatal(err) | |
| 125 | + | } | |
| 126 | + | seen[rt.Name] = rt.Interactive | |
| 127 | + | } | |
| 128 | + | if !seen["ask"] || seen["plain"] { | |
| 129 | + | t.Fatalf("interactive flags = %v", seen) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestInteractiveToolCompletes(t *testing.T) { | |
| 134 | + | h := newInteractiveHarness(t) | |
| 135 | + | h.startRun(t) | |
| 136 | + | h.handshake(t) | |
| 137 | + | h.call(t, "c1") | |
| 138 | + | h.answers <- "yes" | |
| 139 | + | if err := h.awaitEnd(t); err != nil { | |
| 140 | + | t.Fatalf("handler ctx err = %v, want nil", err) | |
| 141 | + | } | |
| 142 | + | f := h.drainUntil(t, "tool_result") | |
| 143 | + | var tr extproto.ToolResultFromExt | |
| 144 | + | if err := json.Unmarshal(f.raw, &tr); err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | if tr.ID != "c1" || tr.IsError || len(tr.Content) != 1 || tr.Content[0].Text != "yes" { | |
| 148 | + | t.Fatalf("tool_result = %+v", tr) | |
| 149 | + | } | |
| 150 | + | // The goroutine's deferred cleanup runs after respondTool; poll briefly. | |
| 151 | + | deadline := time.Now().Add(2 * time.Second) | |
| 152 | + | for { | |
| 153 | + | h.ext.mu.Lock() | |
| 154 | + | inflight := len(h.ext.toolCancels) | |
| 155 | + | h.ext.mu.Unlock() | |
| 156 | + | if inflight == 0 { | |
| 157 | + | break | |
| 158 | + | } | |
| 159 | + | if time.Now().After(deadline) { | |
| 160 | + | t.Fatalf("in-flight cancel handles leaked: %d", inflight) | |
| 161 | + | } | |
| 162 | + | time.Sleep(time.Millisecond) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestToolCancelCancelsHandlerAndSuppressesResult(t *testing.T) { | |
| 167 | + | h := newInteractiveHarness(t) | |
| 168 | + | h.startRun(t) | |
| 169 | + | h.handshake(t) | |
| 170 | + | h.call(t, "c1") | |
| 171 | + | h.call(t, "c2") | |
| 172 | + | h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "c1"}) | |
| 173 | + | if err := h.awaitEnd(t); err != context.Canceled { | |
| 174 | + | t.Fatalf("cancelled handler ctx err = %v", err) | |
| 175 | + | } | |
| 176 | + | h.expectNoToolResult(t) | |
| 177 | + | // The other in-flight call is unaffected and still completes. | |
| 178 | + | h.answers <- "two" | |
| 179 | + | if err := h.awaitEnd(t); err != nil { | |
| 180 | + | t.Fatalf("second handler ctx err = %v", err) | |
| 181 | + | } | |
| 182 | + | f := h.drainUntil(t, "tool_result") | |
| 183 | + | var tr extproto.ToolResultFromExt | |
| 184 | + | _ = json.Unmarshal(f.raw, &tr) | |
| 185 | + | if tr.ID != "c2" || tr.Content[0].Text != "two" { | |
| 186 | + | t.Fatalf("tool_result = %+v", tr) | |
| 187 | + | } | |
| 188 | + | // Unknown / already-finished ids are ignored without side effects. | |
| 189 | + | h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "c1"}) | |
| 190 | + | h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "nope"}) | |
| 191 | + | h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "c3", Name: "plain", Args: json.RawMessage(`{}`)}) | |
| 192 | + | f = h.drainUntil(t, "tool_result") | |
| 193 | + | _ = json.Unmarshal(f.raw, &tr) | |
| 194 | + | if tr.ID != "c3" || tr.Content[0].Text != "plain-ok" { | |
| 195 | + | t.Fatalf("plain tool after cancels = %+v", tr) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestShutdownCancelsInteractiveHandlers(t *testing.T) { | |
| 200 | + | h := newInteractiveHarness(t) | |
| 201 | + | h.startRun(t) | |
| 202 | + | h.handshake(t) | |
| 203 | + | h.call(t, "c1") | |
| 204 | + | h.sendToExt(t, extproto.ShutdownFromHost{Type: "shutdown"}) | |
| 205 | + | if err := h.awaitEnd(t); err != context.Canceled { | |
| 206 | + | t.Fatalf("handler ctx err on shutdown = %v", err) | |
| 207 | + | } | |
| 208 | + | h.drainUntil(t, "shutdown_ack") | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestHostEOFCancelsInteractiveHandlers(t *testing.T) { | |
| 212 | + | h := newInteractiveHarness(t) | |
| 213 | + | runDone := make(chan error, 1) | |
| 214 | + | go func() { runDone <- h.ext.Run() }() | |
| 215 | + | h.handshake(t) | |
| 216 | + | h.call(t, "c1") | |
| 217 | + | if err := h.hostW.Close(); err != nil { | |
| 218 | + | t.Fatal(err) | |
| 219 | + | } | |
| 220 | + | if err := h.awaitEnd(t); err != context.Canceled { | |
| 221 | + | t.Fatalf("handler ctx err on EOF = %v", err) | |
| 222 | + | } | |
| 223 | + | select { | |
| 224 | + | case <-runDone: | |
| 225 | + | case <-time.After(2 * time.Second): | |
| 226 | + | t.Fatal("Run did not return after EOF") | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestPlainToolHandlersRemainSourceCompatible(t *testing.T) { | |
| 231 | + | h := newInteractiveHarness(t) | |
| 232 | + | h.startRun(t) | |
| 233 | + | h.handshake(t) | |
| 234 | + | h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "p1", Name: "plain", Args: json.RawMessage(`{}`)}) | |
| 235 | + | f := h.drainUntil(t, "tool_result") | |
| 236 | + | var tr extproto.ToolResultFromExt | |
| 237 | + | _ = json.Unmarshal(f.raw, &tr) | |
| 238 | + | if tr.ID != "p1" || tr.IsError || tr.Content[0].Text != "plain-ok" { | |
| 239 | + | t.Fatalf("tool_result = %+v", tr) | |
| 240 | + | } | |
| 241 | + | } | |
A packages/agent/extensions/interactive_tool_test.go +624 -0
| @@ -0,0 +1,624 @@ | |||
| 1 | + | package extensions | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/zut/packages/agent/extproto" | |
| 16 | + | "github.com/bnema/zut/packages/core" | |
| 17 | + | "github.com/bnema/zut/packages/provider" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // interactiveHooks marks the host as able to show interactive tool prompts. | |
| 21 | + | type interactiveHooks struct{ stubHooks } | |
| 22 | + | ||
| 23 | + | func (*interactiveHooks) SupportsInteractiveTools() bool { return true } | |
| 24 | + | ||
| 25 | + | // toolPeer wires a Manager to an in-process fake extension over io.Pipe | |
| 26 | + | // pairs. The test plays the extension: it reads host frames from `frames` | |
| 27 | + | // and replies through `replies`. The ordered transport is real, so write | |
| 28 | + | // bounds, disconnects, and cancel frames exercise production code. | |
| 29 | + | type toolPeer struct { | |
| 30 | + | m *Manager | |
| 31 | + | ext *Extension | |
| 32 | + | frames chan []byte // host -> extension frames, one per element | |
| 33 | + | replies *io.PipeWriter // extension -> host (feeds readLoop) | |
| 34 | + | readDone chan struct{} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func newToolPeer(t *testing.T, hooks HostHooks, toolCancel bool) *toolPeer { | |
| 38 | + | return newToolPeerOpts(t, hooks, toolCancel, true) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // newToolPeerOpts builds the peer; drain=false leaves the host's stdin pipe | |
| 42 | + | // unread so every host write blocks in the transport writer. | |
| 43 | + | func newToolPeerOpts(t *testing.T, hooks HostHooks, toolCancel, drain bool) *toolPeer { | |
| 44 | + | t.Helper() | |
| 45 | + | m := New(t.TempDir(), "", "0.0.0-test", "", "", hooks) | |
| 46 | + | log, err := os.CreateTemp(t.TempDir(), "log") | |
| 47 | + | if err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | // Host stdin side: manager writes, test reads. | |
| 51 | + | stdinR, stdinW := io.Pipe() | |
| 52 | + | // Host stdout side: test writes, manager readLoop reads. | |
| 53 | + | stdoutR, stdoutW := io.Pipe() | |
| 54 | + | ext := &Extension{ | |
| 55 | + | Manifest: Manifest{Name: "peer"}, | |
| 56 | + | readyCh: make(chan struct{}), | |
| 57 | + | pending: map[string]chan extproto.CommandResponseFromExt{}, | |
| 58 | + | pendingTool: map[string]chan extproto.ToolResultFromExt{}, | |
| 59 | + | pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}, | |
| 60 | + | eventSubs: map[string]struct{}{}, | |
| 61 | + | interceptSubs: map[string]struct{}{}, | |
| 62 | + | lifecycleFrames: make(chan []byte, 64), | |
| 63 | + | lifecycleStop: make(chan struct{}), | |
| 64 | + | logFile: log, | |
| 65 | + | helloAck: true, | |
| 66 | + | toolCancel: toolCancel, | |
| 67 | + | } | |
| 68 | + | ext.stdin = newOrderedPipe(stdinW) | |
| 69 | + | m.mu.Lock() | |
| 70 | + | m.ext[ext.Manifest.Name] = ext | |
| 71 | + | m.mu.Unlock() | |
| 72 | + | ||
| 73 | + | p := &toolPeer{m: m, ext: ext, frames: make(chan []byte, 64), replies: stdoutW, readDone: make(chan struct{})} | |
| 74 | + | go func() { | |
| 75 | + | m.readLoop(ext, bufio.NewScanner(stdoutR)) | |
| 76 | + | close(p.readDone) | |
| 77 | + | }() | |
| 78 | + | if drain { | |
| 79 | + | go func() { | |
| 80 | + | sc := bufio.NewScanner(stdinR) | |
| 81 | + | sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | |
| 82 | + | for sc.Scan() { | |
| 83 | + | p.frames <- append([]byte(nil), sc.Bytes()...) | |
| 84 | + | } | |
| 85 | + | close(p.frames) | |
| 86 | + | }() | |
| 87 | + | } | |
| 88 | + | t.Cleanup(func() { | |
| 89 | + | _ = stdoutW.Close() | |
| 90 | + | <-p.readDone | |
| 91 | + | _ = stdinR.Close() | |
| 92 | + | _ = log.Close() | |
| 93 | + | }) | |
| 94 | + | return p | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (p *toolPeer) send(t *testing.T, frame any) { | |
| 98 | + | t.Helper() | |
| 99 | + | b, err := extproto.Encode(frame) | |
| 100 | + | if err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | if _, err := p.replies.Write(b); err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (p *toolPeer) register(t *testing.T, name string, interactive bool) { | |
| 109 | + | t.Helper() | |
| 110 | + | p.send(t, extproto.RegisterToolFromExt{Type: "register_tool", Name: name, Schema: json.RawMessage(`{"type":"object"}`), Interactive: interactive}) | |
| 111 | + | p.send(t, extproto.ReadyFromExt{Type: "ready"}) | |
| 112 | + | p.m.WaitForReady(2 * time.Second) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (p *toolPeer) next(t *testing.T) extproto.Frame { | |
| 116 | + | t.Helper() | |
| 117 | + | select { | |
| 118 | + | case raw, ok := <-p.frames: | |
| 119 | + | if !ok { | |
| 120 | + | t.Fatal("host transport closed before expected frame") | |
| 121 | + | } | |
| 122 | + | var f extproto.Frame | |
| 123 | + | if err := json.Unmarshal(raw, &f); err != nil { | |
| 124 | + | t.Fatal(err) | |
| 125 | + | } | |
| 126 | + | return f | |
| 127 | + | case <-time.After(2 * time.Second): | |
| 128 | + | t.Fatal("no frame from host") | |
| 129 | + | return extproto.Frame{} | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (p *toolPeer) expectToolCall(t *testing.T) extproto.Frame { | |
| 134 | + | t.Helper() | |
| 135 | + | f := p.next(t) | |
| 136 | + | if f.Type != "tool_call" { | |
| 137 | + | t.Fatalf("expected tool_call, got %+v", f) | |
| 138 | + | } | |
| 139 | + | return f | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // expectClosedWithout drains until the host transport closes and fails if a | |
| 143 | + | // frame of the given type was seen. Used to prove legacy peers never see | |
| 144 | + | // tool_cancel and headless hosts never dispatch interactive calls. | |
| 145 | + | func (p *toolPeer) expectClosedWithout(t *testing.T, frameType string) { | |
| 146 | + | t.Helper() | |
| 147 | + | for { | |
| 148 | + | select { | |
| 149 | + | case raw, ok := <-p.frames: | |
| 150 | + | if !ok { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | var f extproto.Frame | |
| 154 | + | _ = json.Unmarshal(raw, &f) | |
| 155 | + | if f.Type == frameType { | |
| 156 | + | t.Fatalf("host sent %s: %s", frameType, raw) | |
| 157 | + | } | |
| 158 | + | case <-time.After(2 * time.Second): | |
| 159 | + | t.Fatal("host transport did not close") | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func execTool(tool core.Tool, ctx context.Context) <-chan struct { | |
| 165 | + | res core.ToolResult | |
| 166 | + | err error | |
| 167 | + | } { | |
| 168 | + | done := make(chan struct { | |
| 169 | + | res core.ToolResult | |
| 170 | + | err error | |
| 171 | + | }, 1) | |
| 172 | + | go func() { | |
| 173 | + | res, err := tool.Execute(ctx, nil, nil) | |
| 174 | + | done <- struct { | |
| 175 | + | res core.ToolResult | |
| 176 | + | err error | |
| 177 | + | }{res, err} | |
| 178 | + | }() | |
| 179 | + | return done | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func resultText(res core.ToolResult) string { | |
| 183 | + | var b strings.Builder | |
| 184 | + | for _, c := range res.Content { | |
| 185 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 186 | + | b.WriteString(tb.Text) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | return b.String() | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestInteractiveToolRegistrationAndTimeoutPolicy(t *testing.T) { | |
| 193 | + | p := newToolPeer(t, &interactiveHooks{}, true) | |
| 194 | + | p.send(t, extproto.RegisterToolFromExt{Type: "register_tool", Name: "ask", Schema: json.RawMessage(`{}`), Interactive: true}) | |
| 195 | + | p.send(t, extproto.RegisterToolFromExt{Type: "register_tool", Name: "plain", Schema: json.RawMessage(`{}`)}) | |
| 196 | + | p.send(t, extproto.ReadyFromExt{Type: "ready"}) | |
| 197 | + | p.m.WaitForReady(2 * time.Second) | |
| 198 | + | ||
| 199 | + | infos := p.m.Tools() | |
| 200 | + | if len(infos) != 2 { | |
| 201 | + | t.Fatalf("tools = %+v", infos) | |
| 202 | + | } | |
| 203 | + | for _, info := range infos { | |
| 204 | + | tool := NewTool(p.m, info).(*extensionTool) | |
| 205 | + | switch info.Name { | |
| 206 | + | case "ask": | |
| 207 | + | if !info.Interactive || tool.timeout != 0 || !tool.Interactive() { | |
| 208 | + | t.Fatalf("interactive tool policy wrong: info=%+v timeout=%v", info, tool.timeout) | |
| 209 | + | } | |
| 210 | + | case "plain": | |
| 211 | + | if info.Interactive || tool.timeout != 60*time.Second || tool.Interactive() { | |
| 212 | + | t.Fatalf("normal tool policy wrong: info=%+v timeout=%v", info, tool.timeout) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func TestInteractiveToolCompletesWithoutDeadline(t *testing.T) { | |
| 219 | + | p := newToolPeer(t, &interactiveHooks{}, true) | |
| 220 | + | p.register(t, "ask", true) | |
| 221 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 222 | + | ||
| 223 | + | done := execTool(tool, context.Background()) | |
| 224 | + | call := p.expectToolCall(t) | |
| 225 | + | p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "answer"}}}) | |
| 226 | + | out := <-done | |
| 227 | + | if out.err != nil || out.res.IsError || resultText(out.res) != "answer" { | |
| 228 | + | t.Fatalf("unexpected result: %+v err=%v", out.res, out.err) | |
| 229 | + | } | |
| 230 | + | p.ext.mu.Lock() | |
| 231 | + | pending := len(p.ext.pendingTool) | |
| 232 | + | p.ext.mu.Unlock() | |
| 233 | + | if pending != 0 { | |
| 234 | + | t.Fatalf("pending slots leaked: %d", pending) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func TestInteractiveToolResultSurvivesImmediateDisconnect(t *testing.T) { | |
| 239 | + | for range 30 { | |
| 240 | + | p := newToolPeer(t, &interactiveHooks{}, true) | |
| 241 | + | p.register(t, "ask", true) | |
| 242 | + | done := execTool(NewTool(p.m, p.m.Tools()[0]), context.Background()) | |
| 243 | + | call := p.expectToolCall(t) | |
| 244 | + | p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "answer"}}}) | |
| 245 | + | if err := p.replies.Close(); err != nil { | |
| 246 | + | t.Fatal(err) | |
| 247 | + | } | |
| 248 | + | <-p.readDone | |
| 249 | + | select { | |
| 250 | + | case out := <-done: | |
| 251 | + | if out.err != nil || out.res.IsError || resultText(out.res) != "answer" { | |
| 252 | + | t.Fatalf("completed reply lost to disconnect: result=%+v err=%v", out.res, out.err) | |
| 253 | + | } | |
| 254 | + | case <-time.After(2 * time.Second): | |
| 255 | + | t.Fatal("tool call remained blocked after disconnect") | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestInteractiveToolContextCancelSendsToolCancelAndIgnoresLateResult(t *testing.T) { | |
| 261 | + | p := newToolPeer(t, &interactiveHooks{}, true) | |
| 262 | + | p.register(t, "ask", true) | |
| 263 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 264 | + | ||
| 265 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 266 | + | done := execTool(tool, ctx) | |
| 267 | + | call := p.expectToolCall(t) | |
| 268 | + | cancel() | |
| 269 | + | out := <-done | |
| 270 | + | if out.err != nil || !out.res.IsError || !strings.Contains(resultText(out.res), "cancelled") { | |
| 271 | + | t.Fatalf("expected cancelled tool error, got %+v err=%v", out.res, out.err) | |
| 272 | + | } | |
| 273 | + | f := p.next(t) | |
| 274 | + | if f.Type != "tool_cancel" || f.ID != call.ID { | |
| 275 | + | t.Fatalf("expected tool_cancel for %s, got %+v", call.ID, f) | |
| 276 | + | } | |
| 277 | + | // A late reply for the abandoned id must be ignored and the pending map | |
| 278 | + | // must stay empty. | |
| 279 | + | p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID}) | |
| 280 | + | // Prove the read loop is still healthy by completing a fresh call. | |
| 281 | + | done = execTool(tool, context.Background()) | |
| 282 | + | call2 := p.expectToolCall(t) | |
| 283 | + | if call2.ID == call.ID { | |
| 284 | + | t.Fatal("correlation ids repeated") | |
| 285 | + | } | |
| 286 | + | p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call2.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "ok"}}}) | |
| 287 | + | if out := <-done; out.err != nil || out.res.IsError { | |
| 288 | + | t.Fatalf("follow-up call failed: %+v err=%v", out.res, out.err) | |
| 289 | + | } | |
| 290 | + | p.ext.mu.Lock() | |
| 291 | + | pending := len(p.ext.pendingTool) | |
| 292 | + | p.ext.mu.Unlock() | |
| 293 | + | if pending != 0 { | |
| 294 | + | t.Fatalf("pending slots leaked: %d", pending) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestInteractiveToolContextDeadlineEndsCall(t *testing.T) { | |
| 299 | + | p := newToolPeer(t, &interactiveHooks{}, true) | |
| 300 | + | p.register(t, "ask", true) | |
| 301 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 302 | + | ||
| 303 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 304 | + | defer cancel() | |
| 305 | + | done := execTool(tool, ctx) | |
| 306 | + | call := p.expectToolCall(t) | |
| 307 | + | out := <-done | |
| 308 | + | if out.err != nil || !out.res.IsError || !strings.Contains(resultText(out.res), "deadline") { | |
| 309 | + | t.Fatalf("expected deadline tool error, got %+v err=%v", out.res, out.err) | |
| 310 | + | } | |
| 311 | + | if f := p.next(t); f.Type != "tool_cancel" || f.ID != call.ID { | |
| 312 | + | t.Fatalf("expected tool_cancel, got %+v", f) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func TestNormalToolReplyTimeoutSendsToolCancel(t *testing.T) { | |
| 317 | + | p := newToolPeer(t, &interactiveHooks{}, true) | |
| 318 | + | p.register(t, "plain", false) | |
| 319 | + | ||
| 320 | + | done := make(chan error, 1) | |
| 321 | + | go func() { | |
| 322 | + | _, err := p.m.InvokeTool(context.Background(), "plain", json.RawMessage(`{}`), 20*time.Millisecond) | |
| 323 | + | done <- err | |
| 324 | + | }() | |
| 325 | + | call := p.expectToolCall(t) | |
| 326 | + | err := <-done | |
| 327 | + | if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "timeout waiting for peer/plain") { | |
| 328 | + | t.Fatalf("expected reply timeout, got %v", err) | |
| 329 | + | } | |
| 330 | + | if f := p.next(t); f.Type != "tool_cancel" || f.ID != call.ID { | |
| 331 | + | t.Fatalf("expected tool_cancel, got %+v", f) | |
| 332 | + | } | |
| 333 | + | // The wrapper reports a plain timeout as an ordinary tool error. | |
| 334 | + | tool := NewTool(p.m, p.m.Tools()[0]).(*extensionTool) | |
| 335 | + | tool.timeout = 20 * time.Millisecond | |
| 336 | + | res, execErr := tool.Execute(context.Background(), nil, nil) | |
| 337 | + | p.expectToolCall(t) | |
| 338 | + | if execErr != nil || !res.IsError || !strings.Contains(resultText(res), "failed: timeout") { | |
| 339 | + | t.Fatalf("expected timeout tool error, got %+v err=%v", res, execErr) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func TestLegacyPeerNeverReceivesToolCancel(t *testing.T) { | |
| 344 | + | p := newToolPeer(t, &interactiveHooks{}, false) | |
| 345 | + | p.register(t, "ask", true) | |
| 346 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 347 | + | ||
| 348 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 349 | + | done := execTool(tool, ctx) | |
| 350 | + | p.expectToolCall(t) | |
| 351 | + | cancel() | |
| 352 | + | if out := <-done; out.err != nil || !out.res.IsError { | |
| 353 | + | t.Fatalf("expected cancelled tool error, got %+v err=%v", out.res, out.err) | |
| 354 | + | } | |
| 355 | + | // Legacy registration without the interactive field keeps the old | |
| 356 | + | // timeout, and cancellation never produces a tool_cancel frame. | |
| 357 | + | p.m.Stop(time.Second) | |
| 358 | + | p.expectClosedWithout(t, "tool_cancel") | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | func TestInteractiveToolDisconnectEndsCall(t *testing.T) { | |
| 362 | + | p := newToolPeer(t, &interactiveHooks{}, true) | |
| 363 | + | p.register(t, "ask", true) | |
| 364 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 365 | + | ||
| 366 | + | done := execTool(tool, context.Background()) | |
| 367 | + | p.expectToolCall(t) | |
| 368 | + | // Extension exits: its stdout closes, readLoop ends and tears the | |
| 369 | + | // transport down. | |
| 370 | + | _ = p.replies.Close() | |
| 371 | + | out := <-done | |
| 372 | + | if out.err != nil || !out.res.IsError || !strings.Contains(resultText(out.res), "disconnected") { | |
| 373 | + | t.Fatalf("expected disconnect tool error, got %+v err=%v", out.res, out.err) | |
| 374 | + | } | |
| 375 | + | if p.m.HasTool("ask") { | |
| 376 | + | t.Fatal("tool still registered after disconnect") | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func TestInteractiveToolShutdownEndsCall(t *testing.T) { | |
| 381 | + | p := newToolPeer(t, &interactiveHooks{}, true) | |
| 382 | + | p.register(t, "ask", true) | |
| 383 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 384 | + | ||
| 385 | + | done := execTool(tool, context.Background()) | |
| 386 | + | p.expectToolCall(t) | |
| 387 | + | stopped := make(chan struct{}) | |
| 388 | + | go func() { | |
| 389 | + | p.m.Stop(200 * time.Millisecond) | |
| 390 | + | close(stopped) | |
| 391 | + | }() | |
| 392 | + | out := <-done | |
| 393 | + | if out.err != nil || !out.res.IsError { | |
| 394 | + | t.Fatalf("expected tool error on shutdown, got %+v err=%v", out.res, out.err) | |
| 395 | + | } | |
| 396 | + | select { | |
| 397 | + | case <-stopped: | |
| 398 | + | case <-time.After(3 * time.Second): | |
| 399 | + | t.Fatal("Stop blocked on the pending interactive call") | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func TestInteractiveToolBlockedWriteIsBoundedAndCancellable(t *testing.T) { | |
| 404 | + | // The extension never reads stdin, so the tool_call write blocks in the | |
| 405 | + | // transport writer. Context cancellation must return promptly instead of | |
| 406 | + | // waiting on the (deadline-free) reply, and must not pin goroutines. | |
| 407 | + | t.Run("cancel", func(t *testing.T) { | |
| 408 | + | p := newToolPeerOpts(t, &interactiveHooks{}, true, false) | |
| 409 | + | p.register(t, "ask", true) | |
| 410 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 411 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 412 | + | done := execTool(tool, ctx) | |
| 413 | + | // Wait for the frame to reach the writer goroutine (blocked on the | |
| 414 | + | // unread pipe), then cancel. | |
| 415 | + | pipe := p.ext.stdin.(*orderedPipe) | |
| 416 | + | for { | |
| 417 | + | pipe.mu.Lock() | |
| 418 | + | queued := len(pipe.queue) | |
| 419 | + | pipe.mu.Unlock() | |
| 420 | + | if queued == 0 { | |
| 421 | + | break | |
| 422 | + | } | |
| 423 | + | time.Sleep(time.Millisecond) | |
| 424 | + | } | |
| 425 | + | cancel() | |
| 426 | + | select { | |
| 427 | + | case out := <-done: | |
| 428 | + | if out.err != nil || !out.res.IsError { | |
| 429 | + | t.Fatalf("expected tool error, got %+v err=%v", out.res, out.err) | |
| 430 | + | } | |
| 431 | + | case <-time.After(3 * time.Second): | |
| 432 | + | t.Fatal("cancelled call stayed blocked on the transport write") | |
| 433 | + | } | |
| 434 | + | }) | |
| 435 | + | // Without cancellation the write itself is still bounded, independent of | |
| 436 | + | // the absent reply deadline: the transport times out and disconnects. | |
| 437 | + | t.Run("timeout", func(t *testing.T) { | |
| 438 | + | p := newToolPeerOpts(t, &interactiveHooks{}, true, false) | |
| 439 | + | p.register(t, "ask", true) | |
| 440 | + | pipe := p.ext.stdin.(*orderedPipe) | |
| 441 | + | done := make(chan error, 1) | |
| 442 | + | go func() { | |
| 443 | + | _, err := pipe.writeContext(context.Background(), []byte("{}\n"), 30*time.Millisecond) | |
| 444 | + | done <- err | |
| 445 | + | }() | |
| 446 | + | select { | |
| 447 | + | case err := <-done: | |
| 448 | + | if !errors.Is(err, context.DeadlineExceeded) { | |
| 449 | + | t.Fatalf("expected transport timeout, got %v", err) | |
| 450 | + | } | |
| 451 | + | case <-time.After(3 * time.Second): | |
| 452 | + | t.Fatal("blocked write not bounded") | |
| 453 | + | } | |
| 454 | + | // A subsequent tool call fails immediately on the dead transport. | |
| 455 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 456 | + | res, err := tool.Execute(context.Background(), nil, nil) | |
| 457 | + | if err != nil || !res.IsError || !strings.Contains(resultText(res), "write:") { | |
| 458 | + | t.Fatalf("expected write failure, got %+v err=%v", res, err) | |
| 459 | + | } | |
| 460 | + | }) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func TestOrderedPipeWriteTimeoutDisconnects(t *testing.T) { | |
| 464 | + | r, w := io.Pipe() | |
| 465 | + | pipe := newOrderedPipe(w) | |
| 466 | + | t.Cleanup(func() { _ = r.Close() }) | |
| 467 | + | // Nobody reads r, so the first write blocks in the writer goroutine and | |
| 468 | + | // the second waits in the queue. | |
| 469 | + | first := make(chan error, 1) | |
| 470 | + | go func() { | |
| 471 | + | _, err := pipe.writeContext(context.Background(), []byte("a\n"), 30*time.Millisecond) | |
| 472 | + | first <- err | |
| 473 | + | }() | |
| 474 | + | select { | |
| 475 | + | case err := <-first: | |
| 476 | + | if !errors.Is(err, context.DeadlineExceeded) { | |
| 477 | + | t.Fatalf("expected timeout, got %v", err) | |
| 478 | + | } | |
| 479 | + | case <-time.After(3 * time.Second): | |
| 480 | + | t.Fatal("bounded write did not return") | |
| 481 | + | } | |
| 482 | + | select { | |
| 483 | + | case <-pipe.Done(): | |
| 484 | + | case <-time.After(time.Second): | |
| 485 | + | t.Fatal("transport not disconnected after write timeout") | |
| 486 | + | } | |
| 487 | + | if _, err := pipe.Write([]byte("b\n")); !errors.Is(err, io.ErrClosedPipe) { | |
| 488 | + | t.Fatalf("write after disconnect = %v", err) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func TestOrderedPipeCancelWithdrawsQueuedFrameWithoutDisconnect(t *testing.T) { | |
| 493 | + | r, w := io.Pipe() | |
| 494 | + | pipe := newOrderedPipe(w) | |
| 495 | + | t.Cleanup(func() { pipe.Close(); _ = r.Close() }) | |
| 496 | + | ||
| 497 | + | // Head frame blocks in the writer; the second frame stays queued. | |
| 498 | + | headAck := make(chan error, 1) | |
| 499 | + | if _, err := pipe.enqueue([]byte("head\n"), headAck); err != nil { | |
| 500 | + | t.Fatal(err) | |
| 501 | + | } | |
| 502 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 503 | + | second := make(chan error, 1) | |
| 504 | + | go func() { | |
| 505 | + | _, err := pipe.writeContext(ctx, []byte("second\n"), time.Minute) | |
| 506 | + | second <- err | |
| 507 | + | }() | |
| 508 | + | cancel() | |
| 509 | + | select { | |
| 510 | + | case err := <-second: | |
| 511 | + | if !errors.Is(err, context.Canceled) { | |
| 512 | + | t.Fatalf("expected cancel, got %v", err) | |
| 513 | + | } | |
| 514 | + | case <-time.After(3 * time.Second): | |
| 515 | + | t.Fatal("queued write did not return on cancel") | |
| 516 | + | } | |
| 517 | + | select { | |
| 518 | + | case <-pipe.Done(): | |
| 519 | + | t.Fatal("cancel of a queued frame must not disconnect the transport") | |
| 520 | + | default: | |
| 521 | + | } | |
| 522 | + | // Reader drains: only the head frame is delivered, the withdrawn one is | |
| 523 | + | // not, and the transport keeps working. | |
| 524 | + | go func() { | |
| 525 | + | _, _ = pipe.Write([]byte("third\n")) | |
| 526 | + | }() | |
| 527 | + | sc := bufio.NewScanner(r) | |
| 528 | + | var got []string | |
| 529 | + | for len(got) < 2 && sc.Scan() { | |
| 530 | + | got = append(got, sc.Text()) | |
| 531 | + | } | |
| 532 | + | if strings.Join(got, ",") != "head,third" { | |
| 533 | + | t.Fatalf("delivered frames = %v", got) | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | func TestOrderedPipeOverflowDisconnects(t *testing.T) { | |
| 538 | + | r, w := io.Pipe() | |
| 539 | + | pipe := newOrderedPipe(w) | |
| 540 | + | t.Cleanup(func() { _ = r.Close() }) | |
| 541 | + | var err error | |
| 542 | + | for i := 0; i <= outboundFrames+1; i++ { | |
| 543 | + | if _, err = pipe.enqueue([]byte("x\n"), nil); err != nil { | |
| 544 | + | break | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | if !errors.Is(err, errOutboundOverflow) { | |
| 548 | + | t.Fatalf("expected overflow, got %v", err) | |
| 549 | + | } | |
| 550 | + | select { | |
| 551 | + | case <-pipe.Done(): | |
| 552 | + | case <-time.After(time.Second): | |
| 553 | + | t.Fatal("overflow did not disconnect") | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | func TestCorrelationIDsAreUniqueUnderConcurrency(t *testing.T) { | |
| 558 | + | const n = 2000 | |
| 559 | + | var mu sync.Mutex | |
| 560 | + | seen := make(map[string]struct{}, n) | |
| 561 | + | var wg sync.WaitGroup | |
| 562 | + | for i := 0; i < n; i++ { | |
| 563 | + | wg.Add(1) | |
| 564 | + | go func() { | |
| 565 | + | defer wg.Done() | |
| 566 | + | id := newCorrelationID() | |
| 567 | + | mu.Lock() | |
| 568 | + | seen[id] = struct{}{} | |
| 569 | + | mu.Unlock() | |
| 570 | + | }() | |
| 571 | + | } | |
| 572 | + | wg.Wait() | |
| 573 | + | if len(seen) != n { | |
| 574 | + | t.Fatalf("got %d unique ids for %d calls", len(seen), n) | |
| 575 | + | } | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | func TestHeadlessHostsRejectInteractiveTools(t *testing.T) { | |
| 579 | + | cases := map[string]HostHooks{ | |
| 580 | + | "missing_method": &stubHooks{}, | |
| 581 | + | "returns_false": &interactiveOptOut{}, | |
| 582 | + | } | |
| 583 | + | for name, hooks := range cases { | |
| 584 | + | t.Run(name, func(t *testing.T) { | |
| 585 | + | p := newToolPeer(t, hooks, true) | |
| 586 | + | p.register(t, "ask", true) | |
| 587 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 588 | + | res, err := tool.Execute(context.Background(), nil, nil) | |
| 589 | + | if err != nil || !res.IsError || !strings.Contains(resultText(res), "requires an interactive host") { | |
| 590 | + | t.Fatalf("expected fail-closed rejection, got %+v err=%v", res, err) | |
| 591 | + | } | |
| 592 | + | // Nothing was sent to the extension. | |
| 593 | + | p.m.Stop(time.Second) | |
| 594 | + | p.expectClosedWithout(t, "tool_call") | |
| 595 | + | }) | |
| 596 | + | } | |
| 597 | + | // A non-interactive tool still works on a headless host. | |
| 598 | + | p := newToolPeer(t, &stubHooks{}, true) | |
| 599 | + | p.register(t, "plain", false) | |
| 600 | + | tool := NewTool(p.m, p.m.Tools()[0]) | |
| 601 | + | done := execTool(tool, context.Background()) | |
| 602 | + | call := p.expectToolCall(t) | |
| 603 | + | p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "ok"}}}) | |
| 604 | + | if out := <-done; out.err != nil || out.res.IsError { | |
| 605 | + | t.Fatalf("normal tool on headless host failed: %+v err=%v", out.res, out.err) | |
| 606 | + | } | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | type interactiveOptOut struct{ stubHooks } | |
| 610 | + | ||
| 611 | + | func (*interactiveOptOut) SupportsInteractiveTools() bool { return false } | |
| 612 | + | ||
| 613 | + | func TestIsInteractiveToolClassifiesExtensionTools(t *testing.T) { | |
| 614 | + | m := New(t.TempDir(), "", "0.0.0-test", "", "", &stubHooks{}) | |
| 615 | + | if !IsInteractiveTool(NewTool(m, ToolInfo{Name: "ask", Interactive: true})) { | |
| 616 | + | t.Fatal("interactive tool not classified") | |
| 617 | + | } | |
| 618 | + | if IsInteractiveTool(NewTool(m, ToolInfo{Name: "plain"})) { | |
| 619 | + | t.Fatal("plain tool classified as interactive") | |
| 620 | + | } | |
| 621 | + | if IsInteractiveTool(nil) { | |
| 622 | + | t.Fatal("nil classified as interactive") | |
| 623 | + | } | |
| 624 | + | } | |
M packages/agent/extensions/manager.go +148 -26
| @@ -22,9 +22,11 @@ import ( | |||
| 22 | 22 | "os" | |
| 23 | 23 | "os/exec" | |
| 24 | 24 | "path/filepath" | |
| 25 | + | "slices" | |
| 25 | 26 | "sort" | |
| 26 | 27 | "strings" | |
| 27 | 28 | "sync" | |
| 29 | + | "sync/atomic" | |
| 28 | 30 | "syscall" | |
| 29 | 31 | "time" | |
| 30 | 32 | ||
| @@ -108,11 +110,18 @@ type Extension struct { | |||
| 108 | 110 | stdout io.ReadCloser | |
| 109 | 111 | logFile *os.File | |
| 110 | 112 | helloAck bool | |
| 111 | - | commands []extproto.RegisterCommandFromExt | |
| 112 | - | tools []extproto.RegisterToolFromExt | |
| 113 | - | skills []*skills.Skill | |
| 114 | - | ||
| 115 | - | writeMu sync.Mutex // serializes concurrent host→extension frames | |
| 113 | + | // toolCancel records whether the extension advertised the "tool_cancel" | |
| 114 | + | // capability in hello. Set during the handshake, before publication. | |
| 115 | + | toolCancel bool | |
| 116 | + | commands []extproto.RegisterCommandFromExt | |
| 117 | + | tools []extproto.RegisterToolFromExt | |
| 118 | + | skills []*skills.Skill | |
| 119 | + | ||
| 120 | + | // writeMu serializes concurrent host→extension frames on plain writers. | |
| 121 | + | // Spawned extensions use an orderedPipe, which serializes internally and | |
| 122 | + | // bounds every synchronous write; writeMu is then only used by the | |
| 123 | + | // lifecycle writer's lock probe. | |
| 124 | + | writeMu sync.Mutex | |
| 116 | 125 | ||
| 117 | 126 | // lifecycleFrames decouples session-event delivery from the host's | |
| 118 | 127 | // synchronous control paths. The queue preserves lifecycle ordering while | |
| @@ -192,6 +201,24 @@ type HostHooks interface { | |||
| 192 | 201 | ClearWidget(extName, id string) | |
| 193 | 202 | } | |
| 194 | 203 | ||
| 204 | + | // InteractiveToolHostHooks is an optional extension to HostHooks. A host | |
| 205 | + | // that can actually show extension panels and wait for a person returns true | |
| 206 | + | // from SupportsInteractiveTools. Hosts that do not implement it, or return | |
| 207 | + | // false, are headless: interactive tool calls fail closed with a tool error | |
| 208 | + | // instead of waiting forever on an invisible prompt. | |
| 209 | + | type InteractiveToolHostHooks interface { | |
| 210 | + | SupportsInteractiveTools() bool | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // supportsInteractiveTools reports whether the current host opted in. | |
| 214 | + | func (m *Manager) supportsInteractiveTools() bool { | |
| 215 | + | if m == nil { | |
| 216 | + | return false | |
| 217 | + | } | |
| 218 | + | h, ok := m.hooks.(InteractiveToolHostHooks) | |
| 219 | + | return ok && h.SupportsInteractiveTools() | |
| 220 | + | } | |
| 221 | + | ||
| 195 | 222 | // AlertHostHooks is an optional extension to HostHooks. Hosts that support | |
| 196 | 223 | // structured alerts implement it without breaking older embedders that only | |
| 197 | 224 | // implement the original HostHooks surface. | |
| @@ -724,6 +751,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 724 | 751 | } | |
| 725 | 752 | // Trust the manifest's name; ignore mismatch from the hello. | |
| 726 | 753 | ext.helloAck = true | |
| 754 | + | ext.toolCancel = slices.Contains(hello.Capabilities, "tool_cancel") | |
| 727 | 755 | ||
| 728 | 756 | m.mu.RLock() | |
| 729 | 757 | var activeSession *extproto.SessionContext | |
| @@ -746,6 +774,11 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 746 | 774 | return fmt.Errorf("send hello_ack (stderr log: %s): %w", logPath, err) | |
| 747 | 775 | } | |
| 748 | 776 | ||
| 777 | + | // Every frame after the handshake goes through the bounded, ordered | |
| 778 | + | // transport so a subprocess that stops reading stdin cannot pin host | |
| 779 | + | // goroutines indefinitely. | |
| 780 | + | ext.stdin = newOrderedPipe(stdin) | |
| 781 | + | ||
| 749 | 782 | cleanup = false | |
| 750 | 783 | ||
| 751 | 784 | // Spin up the lifecycle writer and read loop now that the handshake is done. | |
| @@ -828,6 +861,13 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) { | |||
| 828 | 861 | // Returns when stdout closes. | |
| 829 | 862 | func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |
| 830 | 863 | defer func() { | |
| 864 | + | // The peer is gone: disconnect the transport so every pending | |
| 865 | + | // synchronous call (including deadline-free interactive tools) | |
| 866 | + | // observes the disconnect instead of waiting for a reply that | |
| 867 | + | // can never arrive. | |
| 868 | + | if ext.stdin != nil { | |
| 869 | + | _ = ext.stdin.Close() | |
| 870 | + | } | |
| 831 | 871 | // On close, drop every command + tool this extension owned so | |
| 832 | 872 | // future invocations don't dangle. The subprocess is gone; we | |
| 833 | 873 | // won't hear back about its commands or tool calls anymore. | |
| @@ -1053,18 +1093,35 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 1053 | 1093 | ||
| 1054 | 1094 | const lifecycleWriteGrace = 250 * time.Millisecond | |
| 1055 | 1095 | ||
| 1096 | + | // writeFrame sends one synchronous host frame. On the ordered transport the | |
| 1097 | + | // write is serialized and bounded by transportWriteTimeout; on a plain | |
| 1098 | + | // writer (tests, legacy embedders) writeMu provides the serialization. | |
| 1056 | 1099 | func (ext *Extension) writeFrame(frame []byte) (int, error) { | |
| 1057 | 1100 | if ext == nil || ext.stdin == nil { | |
| 1058 | 1101 | return 0, fmt.Errorf("extension stdin is closed") | |
| 1059 | 1102 | } | |
| 1103 | + | if pipe, ok := ext.stdin.(*orderedPipe); ok { | |
| 1104 | + | return pipe.Write(frame) | |
| 1105 | + | } | |
| 1060 | 1106 | ext.writeMu.Lock() | |
| 1061 | 1107 | defer ext.writeMu.Unlock() | |
| 1062 | 1108 | return ext.stdin.Write(frame) | |
| 1063 | 1109 | } | |
| 1064 | 1110 | ||
| 1065 | - | // tryWriteLifecycleFrame bounds only a lifecycle writer's wait for the shared | |
| 1066 | - | // writer lock. Synchronous host frames retain their existing write behavior; | |
| 1067 | - | // a stalled lifecycle snapshot is dropped rather than delaying them. | |
| 1111 | + | // orderedStdin returns the bounded transport when the extension was spawned | |
| 1112 | + | // by this manager. The bool is false for plain writers. | |
| 1113 | + | func (ext *Extension) orderedStdin() (*orderedPipe, bool) { | |
| 1114 | + | if ext == nil || ext.stdin == nil { | |
| 1115 | + | return nil, false | |
| 1116 | + | } | |
| 1117 | + | pipe, ok := ext.stdin.(*orderedPipe) | |
| 1118 | + | return pipe, ok | |
| 1119 | + | } | |
| 1120 | + | ||
| 1121 | + | // tryWriteLifecycleFrame bounds a lifecycle writer's wait for writeMu; a | |
| 1122 | + | // snapshot that cannot acquire it is dropped. Frames are serialized and bounded | |
| 1123 | + | // by the ordered transport: a blocked pipe write still triggers its timeout | |
| 1124 | + | // and disconnects the extension. | |
| 1068 | 1125 | func (ext *Extension) tryWriteLifecycleFrame(frame []byte) error { | |
| 1069 | 1126 | if ext == nil || ext.stdin == nil { | |
| 1070 | 1127 | return fmt.Errorf("extension stdin is closed") | |
| @@ -1283,6 +1340,8 @@ type ToolInfo struct { | |||
| 1283 | 1340 | Description string | |
| 1284 | 1341 | Schema json.RawMessage | |
| 1285 | 1342 | Deferred bool | |
| 1343 | + | // Interactive tools wait for user input and carry no reply deadline. | |
| 1344 | + | Interactive bool | |
| 1286 | 1345 | } | |
| 1287 | 1346 | ||
| 1288 | 1347 | // Tools returns a snapshot of every (extension, tool) pair currently | |
| @@ -1300,6 +1359,7 @@ func (m *Manager) Tools() []ToolInfo { | |||
| 1300 | 1359 | Description: t.Description, | |
| 1301 | 1360 | Schema: t.Schema, | |
| 1302 | 1361 | Deferred: t.Deferred, | |
| 1362 | + | Interactive: t.Interactive, | |
| 1303 | 1363 | }) | |
| 1304 | 1364 | } | |
| 1305 | 1365 | } | |
| @@ -1335,7 +1395,18 @@ func (m *Manager) HasTool(name string) bool { | |||
| 1335 | 1395 | // InvokeTool sends a tool_call to the owning extension and waits for | |
| 1336 | 1396 | // the matching tool_result. Used by the core.Tool wrapper that the | |
| 1337 | 1397 | // agent registers per extension-defined tool. | |
| 1398 | + | // | |
| 1399 | + | // timeout bounds the wait for the reply; timeout <= 0 means no reply | |
| 1400 | + | // deadline (interactive tools). Regardless of timeout, the request write is | |
| 1401 | + | // bounded by the transport, and context cancellation, context deadlines, and | |
| 1402 | + | // an extension disconnect always end the call. When the call is abandoned | |
| 1403 | + | // after the request was delivered, a best-effort tool_cancel is sent to | |
| 1404 | + | // extensions that advertised the "tool_cancel" capability. A late | |
| 1405 | + | // tool_result for an abandoned id is ignored. | |
| 1338 | 1406 | func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) { | |
| 1407 | + | if err := ctx.Err(); err != nil { | |
| 1408 | + | return extproto.ToolResultFromExt{}, err | |
| 1409 | + | } | |
| 1339 | 1410 | m.mu.RLock() | |
| 1340 | 1411 | ext, ok := m.toolIndex[name] | |
| 1341 | 1412 | m.mu.RUnlock() | |
| @@ -1348,6 +1419,11 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||
| 1348 | 1419 | ext.mu.Lock() | |
| 1349 | 1420 | ext.pendingTool[id] = ch | |
| 1350 | 1421 | ext.mu.Unlock() | |
| 1422 | + | defer func() { | |
| 1423 | + | ext.mu.Lock() | |
| 1424 | + | delete(ext.pendingTool, id) | |
| 1425 | + | ext.mu.Unlock() | |
| 1426 | + | }() | |
| 1351 | 1427 | ||
| 1352 | 1428 | frame, _ := extproto.Encode(extproto.ToolCallFromHost{ | |
| 1353 | 1429 | Type: "tool_call", | |
| @@ -1355,26 +1431,57 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||
| 1355 | 1431 | Name: name, | |
| 1356 | 1432 | Args: args, | |
| 1357 | 1433 | }) | |
| 1358 | - | if _, err := ext.writeFrame(frame); err != nil { | |
| 1359 | - | ext.mu.Lock() | |
| 1360 | - | delete(ext.pendingTool, id) | |
| 1361 | - | ext.mu.Unlock() | |
| 1434 | + | var disconnected <-chan struct{} | |
| 1435 | + | var err error | |
| 1436 | + | pipe, ordered := ext.orderedStdin() | |
| 1437 | + | if ordered { | |
| 1438 | + | disconnected = pipe.Done() | |
| 1439 | + | _, err = pipe.writeContext(ctx, frame, transportWriteTimeout) | |
| 1440 | + | } else { | |
| 1441 | + | _, err = ext.writeFrame(frame) | |
| 1442 | + | } | |
| 1443 | + | if err != nil { | |
| 1362 | 1444 | return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err) | |
| 1363 | 1445 | } | |
| 1364 | 1446 | ||
| 1447 | + | // cancelRemote tells a capable extension to abandon the delivered | |
| 1448 | + | // invocation. Enqueue only: the caller is already leaving and must not | |
| 1449 | + | // block on a stalled pipe. | |
| 1450 | + | cancelRemote := func() { | |
| 1451 | + | if !ordered || !ext.toolCancel { | |
| 1452 | + | return | |
| 1453 | + | } | |
| 1454 | + | cancelFrame, encErr := extproto.Encode(extproto.ToolCancelFromHost{Type: "tool_cancel", ID: id}) | |
| 1455 | + | if encErr != nil { | |
| 1456 | + | return | |
| 1457 | + | } | |
| 1458 | + | _, _ = pipe.enqueue(cancelFrame, nil) | |
| 1459 | + | } | |
| 1460 | + | ||
| 1461 | + | var deadline <-chan time.Time | |
| 1462 | + | if timeout > 0 { | |
| 1463 | + | timer := time.NewTimer(timeout) | |
| 1464 | + | defer timer.Stop() | |
| 1465 | + | deadline = timer.C | |
| 1466 | + | } | |
| 1365 | 1467 | select { | |
| 1366 | 1468 | case resp := <-ch: | |
| 1367 | 1469 | return resp, nil | |
| 1368 | - | case <-time.After(timeout): | |
| 1369 | - | ext.mu.Lock() | |
| 1370 | - | delete(ext.pendingTool, id) | |
| 1371 | - | ext.mu.Unlock() | |
| 1372 | - | return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name) | |
| 1470 | + | case <-deadline: | |
| 1471 | + | cancelRemote() | |
| 1472 | + | return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s: %w", ext.Manifest.Name, name, context.DeadlineExceeded) | |
| 1373 | 1473 | case <-ctx.Done(): | |
| 1374 | - | ext.mu.Lock() | |
| 1375 | - | delete(ext.pendingTool, id) | |
| 1376 | - | ext.mu.Unlock() | |
| 1474 | + | cancelRemote() | |
| 1377 | 1475 | return extproto.ToolResultFromExt{}, ctx.Err() | |
| 1476 | + | case <-disconnected: | |
| 1477 | + | // readLoop may have delivered a result immediately before EOF. Prefer | |
| 1478 | + | // that completed reply over the simultaneously ready disconnect. | |
| 1479 | + | select { | |
| 1480 | + | case resp := <-ch: | |
| 1481 | + | return resp, nil | |
| 1482 | + | default: | |
| 1483 | + | } | |
| 1484 | + | return extproto.ToolResultFromExt{}, fmt.Errorf("extension %s disconnected", ext.Manifest.Name) | |
| 1378 | 1485 | } | |
| 1379 | 1486 | } | |
| 1380 | 1487 | ||
| @@ -1482,17 +1589,30 @@ func (m *Manager) Stop(gracePeriod time.Duration) { | |||
| 1482 | 1589 | } | |
| 1483 | 1590 | ||
| 1484 | 1591 | func stopExtensions(exts []*Extension, gracePeriod time.Duration) { | |
| 1592 | + | deadline := time.Now().Add(gracePeriod) | |
| 1485 | 1593 | for _, ext := range exts { | |
| 1486 | 1594 | if ext.stdin == nil { | |
| 1487 | 1595 | continue | |
| 1488 | 1596 | } | |
| 1489 | 1597 | if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil { | |
| 1490 | - | _, _ = ext.writeFrame(frame) | |
| 1598 | + | if pipe, ok := ext.orderedStdin(); ok { | |
| 1599 | + | // Never wait longer than the shutdown grace for a peer that | |
| 1600 | + | // stopped reading; the process is killed below anyway. | |
| 1601 | + | wait := time.Until(deadline) | |
| 1602 | + | if wait > transportWriteTimeout { | |
| 1603 | + | wait = transportWriteTimeout | |
| 1604 | + | } | |
| 1605 | + | if wait < 50*time.Millisecond { | |
| 1606 | + | wait = 50 * time.Millisecond | |
| 1607 | + | } | |
| 1608 | + | _, _ = pipe.writeContext(context.Background(), frame, wait) | |
| 1609 | + | } else { | |
| 1610 | + | _, _ = ext.writeFrame(frame) | |
| 1611 | + | } | |
| 1491 | 1612 | } | |
| 1492 | 1613 | _ = ext.stdin.Close() | |
| 1493 | 1614 | } | |
| 1494 | 1615 | ||
| 1495 | - | deadline := time.Now().Add(gracePeriod) | |
| 1496 | 1616 | for _, ext := range exts { | |
| 1497 | 1617 | if ext.cmd == nil { | |
| 1498 | 1618 | if ext.logFile != nil { | |
| @@ -1535,9 +1655,11 @@ func (m *Manager) All() []*Extension { | |||
| 1535 | 1655 | return out | |
| 1536 | 1656 | } | |
| 1537 | 1657 | ||
| 1538 | - | // newCorrelationID returns a short non-cryptographic id. We don't | |
| 1539 | - | // need uniqueness across processes, just within the lifetime of one | |
| 1540 | - | // extension's pending map. | |
| 1658 | + | var correlationSequence atomic.Uint64 | |
| 1659 | + | ||
| 1660 | + | // newCorrelationID returns an id that is unique within this host process. | |
| 1661 | + | // Two invocations issued in the same microsecond must never share a pending | |
| 1662 | + | // slot, so a monotonic counter replaces the former clock-derived id. | |
| 1541 | 1663 | func newCorrelationID() string { | |
| 1542 | - | return strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "") | |
| 1664 | + | return fmt.Sprintf("c%d", correlationSequence.Add(1)) | |
| 1543 | 1665 | } | |
A packages/agent/extensions/outbound.go +237 -0
| @@ -0,0 +1,237 @@ | |||
| 1 | + | package extensions | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // orderedPipe serializes every host→extension frame through one writer | |
| 13 | + | // goroutine so a blocked subprocess stdin never pins a host goroutine for an | |
| 14 | + | // unbounded time. Frames are delivered in enqueue order. Synchronous callers | |
| 15 | + | // wait for their frame's write with a bounded timeout or context; fire-and- | |
| 16 | + | // forget callers only pay for the enqueue. | |
| 17 | + | // | |
| 18 | + | // The queue is bounded in frames and bytes. Overflow disconnects the | |
| 19 | + | // extension transport instead of growing without limit or silently dropping | |
| 20 | + | // a frame in the middle of an ordered stream. | |
| 21 | + | type orderedPipe struct { | |
| 22 | + | pipe io.WriteCloser | |
| 23 | + | mu sync.Mutex | |
| 24 | + | queue []*outboundFrame | |
| 25 | + | bytes int | |
| 26 | + | closed bool | |
| 27 | + | wake chan struct{} | |
| 28 | + | done chan struct{} | |
| 29 | + | once sync.Once | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type outboundFrame struct { | |
| 33 | + | data []byte | |
| 34 | + | ack chan error | |
| 35 | + | // writing is set once the writer goroutine has taken ownership of the | |
| 36 | + | // frame. Until then the frame may still be withdrawn safely. | |
| 37 | + | writing bool | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | const ( | |
| 41 | + | outboundBytes = 16 * 1024 * 1024 | |
| 42 | + | outboundFrames = 256 | |
| 43 | + | ||
| 44 | + | // transportWriteTimeout bounds how long a synchronous host frame may wait | |
| 45 | + | // for the extension to drain its stdin. It is independent of any reply | |
| 46 | + | // deadline: an interactive tool may wait forever for a reply, but the | |
| 47 | + | // request itself must be accepted by the subprocess promptly. | |
| 48 | + | transportWriteTimeout = 5 * time.Second | |
| 49 | + | ||
| 50 | + | // abandonGrace is how long a cancelled caller waits for a frame that is | |
| 51 | + | // already mid-write to finish before the transport is disconnected. A | |
| 52 | + | // torn frame followed by unrelated bytes would corrupt the stream, so | |
| 53 | + | // closing is the only safe fallback. | |
| 54 | + | abandonGrace = 250 * time.Millisecond | |
| 55 | + | ) | |
| 56 | + | ||
| 57 | + | var errOutboundOverflow = errors.New("extension outbound queue exceeded its limit") | |
| 58 | + | ||
| 59 | + | func newOrderedPipe(pipe io.WriteCloser) *orderedPipe { | |
| 60 | + | p := &orderedPipe{pipe: pipe, wake: make(chan struct{}, 1), done: make(chan struct{})} | |
| 61 | + | go p.run() | |
| 62 | + | return p | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // enqueue appends a frame and returns a handle for withdrawal. ack, when not | |
| 66 | + | // nil, receives exactly one write outcome unless the frame is withdrawn. | |
| 67 | + | func (p *orderedPipe) enqueue(data []byte, ack chan error) (*outboundFrame, error) { | |
| 68 | + | p.mu.Lock() | |
| 69 | + | if p.closed { | |
| 70 | + | p.mu.Unlock() | |
| 71 | + | return nil, io.ErrClosedPipe | |
| 72 | + | } | |
| 73 | + | if len(p.queue) >= outboundFrames || p.bytes+len(data) > outboundBytes { | |
| 74 | + | p.mu.Unlock() | |
| 75 | + | p.Close() | |
| 76 | + | return nil, errOutboundOverflow | |
| 77 | + | } | |
| 78 | + | frame := &outboundFrame{data: append([]byte(nil), data...), ack: ack} | |
| 79 | + | p.queue = append(p.queue, frame) | |
| 80 | + | p.bytes += len(data) | |
| 81 | + | p.mu.Unlock() | |
| 82 | + | select { | |
| 83 | + | case p.wake <- struct{}{}: | |
| 84 | + | default: | |
| 85 | + | } | |
| 86 | + | return frame, nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // withdraw removes a frame that has not yet started writing. It reports | |
| 90 | + | // false when the writer already owns the frame; in that case the bytes may be | |
| 91 | + | // partially on the wire and cannot be taken back. | |
| 92 | + | func (p *orderedPipe) withdraw(frame *outboundFrame) bool { | |
| 93 | + | p.mu.Lock() | |
| 94 | + | defer p.mu.Unlock() | |
| 95 | + | if frame.writing { | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | for i, queued := range p.queue { | |
| 99 | + | if queued == frame { | |
| 100 | + | p.queue = append(p.queue[:i], p.queue[i+1:]...) | |
| 101 | + | p.bytes -= len(frame.data) | |
| 102 | + | return true | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | // Not queued and not writing: already written or the pipe was closed. | |
| 106 | + | return true | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Write enqueues and waits for the write with the standard transport bound. | |
| 110 | + | func (p *orderedPipe) Write(data []byte) (int, error) { | |
| 111 | + | return p.writeContext(context.Background(), data, transportWriteTimeout) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // writeContext enqueues data and waits until it is written, the timeout | |
| 115 | + | // elapses, ctx ends, or the pipe closes. A frame still waiting in the queue | |
| 116 | + | // is withdrawn on cancellation without disturbing the transport; a frame | |
| 117 | + | // already being written must finish within the cancellation grace or the | |
| 118 | + | // transport is closed to avoid a torn frame. A write timeout disconnects the | |
| 119 | + | // transport whether the frame is queued or in progress. | |
| 120 | + | func (p *orderedPipe) writeContext(ctx context.Context, data []byte, timeout time.Duration) (int, error) { | |
| 121 | + | if err := ctx.Err(); err != nil { | |
| 122 | + | return 0, err | |
| 123 | + | } | |
| 124 | + | if timeout <= 0 { | |
| 125 | + | return 0, fmt.Errorf("extension outbound write timed out: %w", context.DeadlineExceeded) | |
| 126 | + | } | |
| 127 | + | ack := make(chan error, 1) | |
| 128 | + | frame, err := p.enqueue(data, ack) | |
| 129 | + | if err != nil { | |
| 130 | + | return 0, err | |
| 131 | + | } | |
| 132 | + | timer := time.NewTimer(timeout) | |
| 133 | + | defer timer.Stop() | |
| 134 | + | abandon := func(reason error) (int, error) { | |
| 135 | + | if p.withdraw(frame) { | |
| 136 | + | return 0, reason | |
| 137 | + | } | |
| 138 | + | // Mid-write: wait briefly for the writer to finish this frame so a | |
| 139 | + | // complete frame lands or the pipe error surfaces; otherwise close. | |
| 140 | + | select { | |
| 141 | + | case err := <-ack: | |
| 142 | + | if err != nil { | |
| 143 | + | return 0, err | |
| 144 | + | } | |
| 145 | + | return len(data), nil | |
| 146 | + | case <-p.done: | |
| 147 | + | return 0, io.ErrClosedPipe | |
| 148 | + | case <-time.After(abandonGrace): | |
| 149 | + | p.Close() | |
| 150 | + | return 0, reason | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | select { | |
| 154 | + | case err := <-ack: | |
| 155 | + | if err != nil { | |
| 156 | + | return 0, err | |
| 157 | + | } | |
| 158 | + | return len(data), nil | |
| 159 | + | case <-p.done: | |
| 160 | + | // The frame may have completed just before the disconnect. If the | |
| 161 | + | // writer owns it, its outcome arrives promptly because the closed | |
| 162 | + | // pipe fails any in-progress write; report that outcome accurately so | |
| 163 | + | // callers distinguish "request delivered, peer gone" from "never sent". | |
| 164 | + | if !p.withdraw(frame) { | |
| 165 | + | if err := <-ack; err != nil { | |
| 166 | + | return 0, err | |
| 167 | + | } | |
| 168 | + | return len(data), nil | |
| 169 | + | } | |
| 170 | + | select { | |
| 171 | + | case err := <-ack: | |
| 172 | + | if err == nil { | |
| 173 | + | return len(data), nil | |
| 174 | + | } | |
| 175 | + | return 0, err | |
| 176 | + | default: | |
| 177 | + | } | |
| 178 | + | return 0, io.ErrClosedPipe | |
| 179 | + | case <-ctx.Done(): | |
| 180 | + | return abandon(ctx.Err()) | |
| 181 | + | case <-timer.C: | |
| 182 | + | p.Close() | |
| 183 | + | return 0, fmt.Errorf("extension outbound write timed out: %w", context.DeadlineExceeded) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // Close disconnects the transport. Queued frames are discarded and every | |
| 188 | + | // waiter observes done. Safe to call more than once. | |
| 189 | + | func (p *orderedPipe) Close() error { | |
| 190 | + | p.once.Do(func() { | |
| 191 | + | p.mu.Lock() | |
| 192 | + | p.closed = true | |
| 193 | + | p.queue = nil | |
| 194 | + | p.bytes = 0 | |
| 195 | + | p.mu.Unlock() | |
| 196 | + | close(p.done) | |
| 197 | + | _ = p.pipe.Close() | |
| 198 | + | }) | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // Done is closed when the transport has been disconnected. | |
| 203 | + | func (p *orderedPipe) Done() <-chan struct{} { return p.done } | |
| 204 | + | ||
| 205 | + | func (p *orderedPipe) run() { | |
| 206 | + | for { | |
| 207 | + | select { | |
| 208 | + | case <-p.done: | |
| 209 | + | return | |
| 210 | + | case <-p.wake: | |
| 211 | + | } | |
| 212 | + | for { | |
| 213 | + | p.mu.Lock() | |
| 214 | + | if p.closed || len(p.queue) == 0 { | |
| 215 | + | p.mu.Unlock() | |
| 216 | + | break | |
| 217 | + | } | |
| 218 | + | frame := p.queue[0] | |
| 219 | + | frame.writing = true | |
| 220 | + | p.queue[0] = nil | |
| 221 | + | p.queue = p.queue[1:] | |
| 222 | + | p.bytes -= len(frame.data) | |
| 223 | + | p.mu.Unlock() | |
| 224 | + | n, err := p.pipe.Write(frame.data) | |
| 225 | + | if err == nil && n != len(frame.data) { | |
| 226 | + | err = io.ErrShortWrite | |
| 227 | + | } | |
| 228 | + | if frame.ack != nil { | |
| 229 | + | frame.ack <- err | |
| 230 | + | } | |
| 231 | + | if err != nil { | |
| 232 | + | p.Close() | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } | |
M packages/agent/extensions/tool.go +44 -3
| @@ -3,6 +3,7 @@ package extensions | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -35,20 +36,45 @@ type extensionTool struct { | |||
| 35 | 36 | manager *Manager | |
| 36 | 37 | timeout time.Duration | |
| 37 | 38 | deferred bool | |
| 39 | + | interactive bool | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // defaultToolTimeout is the reply deadline for ordinary extension tools. | |
| 43 | + | const defaultToolTimeout = 60 * time.Second | |
| 44 | + | ||
| 45 | + | // InteractiveTool is implemented by extension tools that wait for user | |
| 46 | + | // input. Hosts that build child or headless registries from a parent's | |
| 47 | + | // catalogue can use IsInteractiveTool to exclude them explicitly instead of | |
| 48 | + | // relying on the runtime rejection in Execute. | |
| 49 | + | type InteractiveTool interface { | |
| 50 | + | Interactive() bool | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // IsInteractiveTool reports whether t is an interactive extension tool. | |
| 54 | + | func IsInteractiveTool(t core.Tool) bool { | |
| 55 | + | it, ok := t.(InteractiveTool) | |
| 56 | + | return ok && it.Interactive() | |
| 38 | 57 | } | |
| 39 | 58 | ||
| 40 | 59 | // NewTool returns a core.Tool that round-trips invocations through | |
| 41 | - | // mgr to the extension that registered (name, schema). The default | |
| 42 | - | // per-call timeout is 60 seconds; callers can override. | |
| 60 | + | // mgr to the extension that registered (name, schema). Ordinary tools | |
| 61 | + | // must reply within 60 seconds. Interactive tools have no host reply | |
| 62 | + | // deadline; the agent context, its deadline, and extension disconnects | |
| 63 | + | // still end the call. | |
| 43 | 64 | func NewTool(mgr *Manager, info ToolInfo) core.Tool { | |
| 65 | + | timeout := defaultToolTimeout | |
| 66 | + | if info.Interactive { | |
| 67 | + | timeout = 0 | |
| 68 | + | } | |
| 44 | 69 | return &extensionTool{ | |
| 45 | 70 | name: info.Name, | |
| 46 | 71 | description: info.Description, | |
| 47 | 72 | schema: info.Schema, | |
| 48 | 73 | extension: info.Extension, | |
| 49 | 74 | manager: mgr, | |
| 50 | - | timeout: 60 * time.Second, | |
| 75 | + | timeout: timeout, | |
| 51 | 76 | deferred: info.Deferred, | |
| 77 | + | interactive: info.Interactive, | |
| 52 | 78 | } | |
| 53 | 79 | } | |
| 54 | 80 | ||
| @@ -57,6 +83,7 @@ func (t *extensionTool) Description() string { return t.description } | |||
| 57 | 83 | func (t *extensionTool) Schema() json.RawMessage { return t.schema } | |
| 58 | 84 | func (t *extensionTool) Extension() string { return t.extension } | |
| 59 | 85 | func (t *extensionTool) Deferred() bool { return t.deferred } | |
| 86 | + | func (t *extensionTool) Interactive() bool { return t.interactive } | |
| 60 | 87 | ||
| 61 | 88 | // Execute is what the agent calls when the LLM invokes the tool. It | |
| 62 | 89 | // hands args to the owning extension, waits up to t.timeout for the | |
| @@ -65,8 +92,22 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun | |||
| 65 | 92 | if len(args) == 0 { | |
| 66 | 93 | args = json.RawMessage(`{}`) | |
| 67 | 94 | } | |
| 95 | + | if t.interactive && !t.manager.supportsInteractiveTools() { | |
| 96 | + | // Fail closed: a headless host has nowhere to show the prompt, so | |
| 97 | + | // waiting without a deadline would hang the run. | |
| 98 | + | return core.ToolResult{ | |
| 99 | + | IsError: true, | |
| 100 | + | Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s is interactive and requires an interactive host", t.extension, t.name)}}, | |
| 101 | + | }, nil | |
| 102 | + | } | |
| 68 | 103 | resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout) | |
| 69 | 104 | if err != nil { | |
| 105 | + | if errors.Is(err, context.Canceled) { | |
| 106 | + | return core.ToolResult{ | |
| 107 | + | IsError: true, | |
| 108 | + | Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s cancelled: %v", t.extension, t.name, err)}}, | |
| 109 | + | }, nil | |
| 110 | + | } | |
| 70 | 111 | return core.ToolResult{ | |
| 71 | 112 | IsError: true, | |
| 72 | 113 | Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}}, | |
M packages/agent/extproto/extproto.go +13 -0
| @@ -48,6 +48,11 @@ type RegisterToolFromExt struct { | |||
| 48 | 48 | Description string `json:"description,omitempty"` | |
| 49 | 49 | Schema json.RawMessage `json:"schema"` | |
| 50 | 50 | Deferred bool `json:"deferred,omitempty"` | |
| 51 | + | // Interactive marks a tool that intentionally waits for user input. The | |
| 52 | + | // host applies no reply deadline; context cancellation, deadlines, and | |
| 53 | + | // disconnects still end the call. Headless hosts reject such tools. | |
| 54 | + | // Older hosts ignore the field and keep their normal timeout. | |
| 55 | + | Interactive bool `json:"interactive,omitempty"` | |
| 51 | 56 | } | |
| 52 | 57 | ||
| 53 | 58 | type ReadyFromExt struct { | |
| @@ -247,6 +252,14 @@ type CommandInvokedFromHost struct { | |||
| 247 | 252 | Args string `json:"args,omitempty"` | |
| 248 | 253 | } | |
| 249 | 254 | ||
| 255 | + | // ToolCancelFromHost asks the extension to stop an abandoned tool | |
| 256 | + | // invocation. Sent best-effort, and only to extensions that advertised the | |
| 257 | + | // "tool_cancel" capability in hello. ID is the tool_call correlation id. | |
| 258 | + | type ToolCancelFromHost struct { | |
| 259 | + | Type string `json:"type"` | |
| 260 | + | ID string `json:"id"` | |
| 261 | + | } | |
| 262 | + | ||
| 250 | 263 | type ToolCallFromHost struct { | |
| 251 | 264 | Type string `json:"type"` | |
| 252 | 265 | ID string `json:"id"` | |
A packages/agent/interactive_extension_policy_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/extensions" | |
| 10 | + | "github.com/bnema/zut/packages/agent/modes" | |
| 11 | + | "github.com/bnema/zut/packages/core" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestExtensionAdapterPreservesInteractivePolicy(t *testing.T) { | |
| 15 | + | manager := extensions.New(t.TempDir(), t.TempDir(), "test", "provider", "model", nonInteractiveExtHooks{}) | |
| 16 | + | adapter := &extToolAdapter{mgr: manager} | |
| 17 | + | tool := adapter.NewExtensionTool(ExtensionToolInfo{ | |
| 18 | + | Name: "ask_user", Interactive: true, Schema: json.RawMessage(`{"type":"object"}`), | |
| 19 | + | }) | |
| 20 | + | if !extensions.IsInteractiveTool(tool) { | |
| 21 | + | t.Fatal("adapter lost interactive registration") | |
| 22 | + | } | |
| 23 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil) | |
| 24 | + | if err != nil || !result.IsError { | |
| 25 | + | t.Fatalf("headless execution result=%+v err=%v", result, err) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestInteractiveExtensionSupportRequiresAttachedHost(t *testing.T) { | |
| 30 | + | hooks := &interactiveExtHooks{} | |
| 31 | + | if hooks.SupportsInteractiveTools() { | |
| 32 | + | t.Fatal("unattached host permits interactive tools") | |
| 33 | + | } | |
| 34 | + | hooks.attachInteractive(&modes.Interactive{}) | |
| 35 | + | if !hooks.SupportsInteractiveTools() { | |
| 36 | + | t.Fatal("attached TUI rejects interactive tools") | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestScheduledSessionRegistryExcludesInteractiveExtensionTools(t *testing.T) { | |
| 41 | + | interactive := extensions.NewTool(nil, extensions.ToolInfo{Name: "ask_user", Interactive: true, Schema: json.RawMessage(`{"type":"object"}`)}) | |
| 42 | + | ordinary := extensions.NewTool(nil, extensions.ToolInfo{Name: "lookup", Schema: json.RawMessage(`{"type":"object"}`)}) | |
| 43 | + | registry := scheduledSessionRegistry(core.Registry{"ask_user": interactive, "lookup": ordinary}) | |
| 44 | + | if _, ok := registry["ask_user"]; ok { | |
| 45 | + | t.Fatal("scheduled background session retained an interactive tool") | |
| 46 | + | } | |
| 47 | + | if registry["lookup"] != ordinary { | |
| 48 | + | t.Fatal("scheduled background session lost an ordinary extension tool") | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestResidentRegistryRejectsInteractiveExtensionTools(t *testing.T) { | |
| 53 | + | tool := extensions.NewTool(nil, extensions.ToolInfo{Name: "ask_user", Interactive: true, Schema: json.RawMessage(`{"type":"object"}`)}) | |
| 54 | + | if _, err := residentChildRegistry(core.Registry{"ask_user": tool}, []string{"ask_user"}); err == nil || !strings.Contains(err.Error(), "interactive") { | |
| 55 | + | t.Fatalf("resident registry accepted interactive tool: %v", err) | |
| 56 | + | } | |
| 57 | + | } | |
A packages/agent/modelpicker_providers.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sort" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/provider" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // modelPickerProviders returns providers whose models can be selected without | |
| 10 | + | // prompting for credentials. Any custom provider with a configured endpoint | |
| 11 | + | // (provider-level or per-model URL) is offered, even without a stored key, | |
| 12 | + | // because such endpoints may be keyless; auth:"none" is not required. | |
| 13 | + | func modelPickerProviders() []string { | |
| 14 | + | var out []string | |
| 15 | + | seen := map[string]bool{} | |
| 16 | + | for _, p := range knownProviders { | |
| 17 | + | if CredentialAvailable(p) && !seen[p] { | |
| 18 | + | out = append(out, p) | |
| 19 | + | seen[p] = true | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | custom := provider.CustomProviders() | |
| 23 | + | names := make([]string, 0, len(custom)) | |
| 24 | + | for p := range custom { | |
| 25 | + | names = append(names, p) | |
| 26 | + | } | |
| 27 | + | sort.Strings(names) | |
| 28 | + | for _, p := range names { | |
| 29 | + | if seen[p] { | |
| 30 | + | continue | |
| 31 | + | } | |
| 32 | + | cfg := custom[p] | |
| 33 | + | // Custom endpoints with a configured URL may run keyless, so picker | |
| 34 | + | // availability must not require stored credentials. | |
| 35 | + | keyless := !isBuiltinProvider(p) && (cfg.BaseURL != "" || customProviderHasModelURL(p)) | |
| 36 | + | if keyless || CredentialAvailable(p) { | |
| 37 | + | out = append(out, p) | |
| 38 | + | seen[p] = true | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | // Ollama models are always available (no auth needed). | |
| 42 | + | if !seen["ollama"] { | |
| 43 | + | out = append(out, "ollama") | |
| 44 | + | } | |
| 45 | + | return out | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func customProviderHasModelURL(name string) bool { | |
| 49 | + | for _, m := range provider.ModelsForProvider(name) { | |
| 50 | + | if m.Source == "user" && m.BaseURL != "" { | |
| 51 | + | return true | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | return false | |
| 55 | + | } | |
M packages/agent/modelsync.go +26 -1
| @@ -304,6 +304,9 @@ func ValidateAndRepairConfig() { | |||
| 304 | 304 | // baked-in entries, so an uncached id must survive startup repair. | |
| 305 | 305 | if provider.AcceptsUnlistedModels(cfg.Provider) { | |
| 306 | 306 | // The live refresh will validate the id when it is available. | |
| 307 | + | } else if isDiscoverableCustomProvider(cfg.Provider) { | |
| 308 | + | // Discovery is transient and has not run yet at repair time; keep | |
| 309 | + | // the selection even if another provider has the same model id. | |
| 307 | 310 | } else if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(modelID) { | |
| 308 | 311 | // Provider is a router and the id is route-qualified; preserve it. | |
| 309 | 312 | } else if m, err := provider.FindModel("", modelID); err == nil { | |
| @@ -344,6 +347,8 @@ func RefreshModelsAsync(explicitProvider, explicitAPIKey string, explicitBaseURL | |||
| 344 | 347 | baseURL = explicitBaseURL[0] | |
| 345 | 348 | } | |
| 346 | 349 | go refreshModels(explicitProvider, explicitAPIKey, baseURL, "") | |
| 350 | + | go refreshCopilotModelAvailability() | |
| 351 | + | go refreshCustomProvidersBackground() | |
| 347 | 352 | go func() { | |
| 348 | 353 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 349 | 354 | defer cancel() | |
| @@ -351,6 +356,26 @@ func RefreshModelsAsync(explicitProvider, explicitAPIKey string, explicitBaseURL | |||
| 351 | 356 | }() | |
| 352 | 357 | } | |
| 353 | 358 | ||
| 359 | + | // refreshCopilotModelAvailability filters the Copilot catalog to the models the | |
| 360 | + | // authenticated account can select. Availability is account-specific, so it | |
| 361 | + | // lives only in memory and never in the shared disk cache. It never runs an | |
| 362 | + | // api-key command. Without a credential the restriction is cleared; a failed | |
| 363 | + | // refresh keeps the previous snapshot (or the static catalog on first load). | |
| 364 | + | func refreshCopilotModelAvailability() { | |
| 365 | + | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) | |
| 366 | + | defer cancel() | |
| 367 | + | cred, _, err := resolveCredentialForBackground(ctx, provider.ProviderGitHubCopilot) | |
| 368 | + | if err != nil || cred == "" { | |
| 369 | + | provider.SetModelAvailability(provider.ProviderGitHubCopilot, nil) | |
| 370 | + | return | |
| 371 | + | } | |
| 372 | + | ids, err := provider.DiscoverCopilotAvailableModels(ctx, cred) | |
| 373 | + | if err != nil { | |
| 374 | + | return | |
| 375 | + | } | |
| 376 | + | provider.SetModelAvailability(provider.ProviderGitHubCopilot, ids) | |
| 377 | + | } | |
| 378 | + | ||
| 354 | 379 | func refreshModelsAsyncForProvider(ctx context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) { | |
| 355 | 380 | if ctx == nil { | |
| 356 | 381 | ctx = context.Background() | |
| @@ -381,7 +406,7 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e | |||
| 381 | 406 | if err != nil { | |
| 382 | 407 | return err | |
| 383 | 408 | } | |
| 384 | - | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | |
| 409 | + | provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL)) | |
| 385 | 410 | return nil | |
| 386 | 411 | } | |
| 387 | 412 | ||
M packages/agent/modelsync_test.go +7 -2
| @@ -191,8 +191,10 @@ func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) { | |||
| 191 | 191 | if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil { | |
| 192 | 192 | t.Fatal(err) | |
| 193 | 193 | } | |
| 194 | - | provider.SetManagedModels(nil) | |
| 195 | - | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |
| 194 | + | preserveProviderCatalog(t) | |
| 195 | + | provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, nil) | |
| 196 | + | // A custom-discovery snapshot must survive the llama.cpp refresh. | |
| 197 | + | provider.SetManagedModelsForProvider("m5", []provider.Model{{Provider: "m5", ID: "kept", Source: "live"}}) | |
| 196 | 198 | ||
| 197 | 199 | if err := RefreshLlamaCPPModels(context.Background()); err != nil { | |
| 198 | 200 | t.Fatal(err) | |
| @@ -207,6 +209,9 @@ func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) { | |||
| 207 | 209 | if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil { | |
| 208 | 210 | t.Fatal("unloaded model must not be selectable") | |
| 209 | 211 | } | |
| 212 | + | if _, err := provider.FindModel("m5", "kept"); err != nil { | |
| 213 | + | t.Fatal("llama.cpp refresh dropped another provider's managed models") | |
| 214 | + | } | |
| 210 | 215 | } | |
| 211 | 216 | ||
| 212 | 217 | // TestValidateAndRepairConfig_MismatchedPair simulates the bug from a | |
M packages/agent/modes/clipboard_images.go +4 -1
| @@ -34,7 +34,10 @@ func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) b | |||
| 34 | 34 | if message.Text == "" && len(message.Images) == 0 { | |
| 35 | 35 | return false | |
| 36 | 36 | } | |
| 37 | - | text := message.Text | |
| 37 | + | i.mu.Lock() | |
| 38 | + | text := i.skillPins.rawPrompt(message.Text) | |
| 39 | + | i.skillPins.recall(message.Text) | |
| 40 | + | i.mu.Unlock() | |
| 38 | 41 | attachments := make([]clipboardImageAttachment, 0, len(message.Images)) | |
| 39 | 42 | for index, image := range message.Images { | |
| 40 | 43 | markerIndex := index + 1 | |
M packages/agent/modes/double_escape.go +1 -0
| @@ -90,6 +90,7 @@ func (i *Interactive) sessionTreeEscapeBlocked() bool { | |||
| 90 | 90 | i.settingsDialog != nil && i.settingsDialog.Active() || | |
| 91 | 91 | i.sessionOpsDialog != nil && i.sessionOpsDialog.Active() || | |
| 92 | 92 | i.sessionTreeDialog != nil && i.sessionTreeDialog.Active() || | |
| 93 | + | i.timeline != nil && i.timeline.Active() || | |
| 93 | 94 | i.extPanel != nil && i.extPanel.Active() || | |
| 94 | 95 | i.confirmDialog != nil && i.confirmDialog.Active() { | |
| 95 | 96 | return true | |
M packages/agent/modes/interactive.go +30 -0
| @@ -253,6 +253,12 @@ type InteractiveConfig struct { | |||
| 253 | 253 | // the catalog before /model opens. | |
| 254 | 254 | RefreshLlamaCPPModels func(context.Context) error | |
| 255 | 255 | ||
| 256 | + | // RefreshCustomProviderModels lists models from models.json providers | |
| 257 | + | // that opted into discovery. CustomDiscoveryConfigured gates it so /model | |
| 258 | + | // opens immediately when nothing opted in. Both are optional. | |
| 259 | + | RefreshCustomProviderModels func(context.Context) error | |
| 260 | + | CustomDiscoveryConfigured func() bool | |
| 261 | + | ||
| 256 | 262 | // BuildAgent is called after a successful login to (re)construct the | |
| 257 | 263 | // agent with the fresh credential. It returns the new agent and | |
| 258 | 264 | // the concrete provider/model in use. | |
| @@ -439,6 +445,20 @@ type InteractiveConfig struct { | |||
| 439 | 445 | // so the picker reflects edits made during the session. | |
| 440 | 446 | SkillSnapshot func() []*skills.Skill | |
| 441 | 447 | ||
| 448 | + | // LoadSkillPins returns the pinned-skill preferences for cwd. With | |
| 449 | + | // PreloadPinnedSkills it feeds the first-prompt preload; either way it | |
| 450 | + | // drives the /skills pin markers. Nil disables pinning. | |
| 451 | + | LoadSkillPins func(cwd string) (skills.Pins, error) | |
| 452 | + | ||
| 453 | + | // ToggleSkillPin flips one skill's pin in the project (global=false) or | |
| 454 | + | // global scope. Nil makes the /skills picker read-only. | |
| 455 | + | ToggleSkillPin func(cwd, name string, global bool) error | |
| 456 | + | ||
| 457 | + | // PreloadPinnedSkills arms the pinned-skill preload for a fresh | |
| 458 | + | // conversation. Hosts that resume a session must leave it false or call | |
| 459 | + | // SetPinnedSkillsPending(false) once the session is loaded. | |
| 460 | + | PreloadPinnedSkills bool | |
| 461 | + | ||
| 442 | 462 | // ChangelogChan, if non-nil, delivers release-notes for the | |
| 443 | 463 | // current binary version once at startup. Interactive opens a | |
| 444 | 464 | // dismissible overlay when the channel produces a non-empty | |
| @@ -850,9 +870,14 @@ type Interactive struct { | |||
| 850 | 870 | telegramBridge *telegram.Bridge | |
| 851 | 871 | sessionOpsDialog *sessionOpsDialog | |
| 852 | 872 | sessionTreeDialog *sessionTreeDialog | |
| 873 | + | timeline *timelineView | |
| 853 | 874 | extPanel *extPanelDialog | |
| 854 | 875 | llamaConfigured bool | |
| 855 | 876 | ||
| 877 | + | // skillPins preload state is guarded by mu; its fixed callbacks run | |
| 878 | + | // outside mu, serialized by skillPins.ioMu. | |
| 879 | + | skillPins skillPinState | |
| 880 | + | ||
| 856 | 881 | // completionTracker collects resident child terminal turns for delivery to | |
| 857 | 882 | // the parent orchestration wave. | |
| 858 | 883 | // turnCoordinator seals worker waves at manager-turn boundaries and decides | |
| @@ -1098,6 +1123,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1098 | 1123 | settingsDialog: newSettingsDialog(), | |
| 1099 | 1124 | sessionOpsDialog: newSessionOpsDialog(), | |
| 1100 | 1125 | sessionTreeDialog: newSessionTreeDialog(), | |
| 1126 | + | timeline: newTimelineView(), | |
| 1101 | 1127 | extPanel: newExtPanelDialog(), | |
| 1102 | 1128 | extStatuses: map[string]map[string]extensionStatus{}, | |
| 1103 | 1129 | extWidgets: map[string]map[string]extensionWidget{}, | |
| @@ -1110,6 +1136,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1110 | 1136 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |
| 1111 | 1137 | compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff), | |
| 1112 | 1138 | } | |
| 1139 | + | i.skillPins = newSkillPinState(cfg.LoadSkillPins, cfg.ToggleSkillPin, cfg.SkillSnapshot) | |
| 1113 | 1140 | i.btwDialog.setCloseHook(func() { | |
| 1114 | 1141 | i.confirmDialog.CancelChildConfirmations("side chat closed") | |
| 1115 | 1142 | }) | |
| @@ -1161,6 +1188,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1161 | 1188 | } | |
| 1162 | 1189 | i.applyAutoSubagentsTool() | |
| 1163 | 1190 | i.recoverGoalRun() | |
| 1191 | + | if cfg.PreloadPinnedSkills { | |
| 1192 | + | i.SetPinnedSkillsPending(true) | |
| 1193 | + | } | |
| 1164 | 1194 | return i | |
| 1165 | 1195 | } | |
| 1166 | 1196 | ||
M packages/agent/modes/interactive_commands.go +79 -25
| @@ -3,6 +3,7 @@ package modes | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| @@ -224,7 +225,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 224 | 225 | // lock. It is part of the clear decision so a divergence still clears the | |
| 225 | 226 | // file. | |
| 226 | 227 | persistedPlan := i.cfg.PersistedPlan != nil && i.cfg.PersistedPlan() != nil | |
| 227 | - | i.mu.Lock() | |
| 228 | + | // Pinned-skill discovery and preference loads are file I/O as well: | |
| 229 | + | // prefetch them beside the plan read, publish under the lock below. | |
| 230 | + | pinFetch, pinDone := i.lockWithSkillPinFetch() | |
| 228 | 231 | // Read i.agent under i.mu, the same field the locked Agent accessor | |
| 229 | 232 | // returns; the lock-free read this replaced could race a session swap. | |
| 230 | 233 | clearPlan := i.agent != nil && len(i.agent.CurrentPlan()) > 0 | |
| @@ -242,6 +245,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 242 | 245 | i.sessionInfoBlocks = nil | |
| 243 | 246 | i.parkedTurn = 0 | |
| 244 | 247 | i.parkedTotal = 0 | |
| 248 | + | i.lastCtxInput = 0 | |
| 245 | 249 | i.scrollOffset = 0 | |
| 246 | 250 | i.extNotes = nil | |
| 247 | 251 | i.reloadErrors = nil | |
| @@ -251,7 +255,10 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 251 | 255 | i.planRevision++ | |
| 252 | 256 | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 253 | 257 | i.view.InvalidateRenderCache() | |
| 258 | + | // A cleared conversation gets its pinned skills again. | |
| 259 | + | i.armSkillPinsLocked(pinFetch) | |
| 254 | 260 | i.mu.Unlock() | |
| 261 | + | pinDone() | |
| 255 | 262 | if persistHandoff { | |
| 256 | 263 | i.persistCompactHandoff(handoff) | |
| 257 | 264 | } | |
| @@ -290,16 +297,18 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 290 | 297 | case "/model": | |
| 291 | 298 | if len(parts) >= 2 { | |
| 292 | 299 | i.applyModelSelection("", parts[1]) | |
| 293 | - | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 300 | + | } else if i.modelRefreshNeeded() { | |
| 294 | 301 | i.mu.Lock() | |
| 295 | 302 | i.modelRefreshing = true | |
| 296 | 303 | i.statusOK = "Refreshing models" | |
| 297 | 304 | i.statusErr = "" | |
| 298 | 305 | i.mu.Unlock() | |
| 299 | 306 | go func() { | |
| 300 | - | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 301 | - | defer cancel() | |
| 302 | - | i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)} | |
| 307 | + | err := i.refreshModelCatalogs(ctx) | |
| 308 | + | select { | |
| 309 | + | case i.modelRefresh <- modelRefreshResult{err: err}: | |
| 310 | + | case <-ctx.Done(): | |
| 311 | + | } | |
| 303 | 312 | }() | |
| 304 | 313 | } else { | |
| 305 | 314 | i.openModelPickerAfterRefresh(nil) | |
| @@ -513,6 +522,41 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 513 | 522 | } | |
| 514 | 523 | return false | |
| 515 | 524 | } | |
| 525 | + | ||
| 526 | + | // modelRefreshNeeded reports whether /model must synchronize an optional | |
| 527 | + | // transient catalog (llama.cpp router or opted-in custom discovery) first. | |
| 528 | + | func (i *Interactive) modelRefreshNeeded() bool { | |
| 529 | + | if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 530 | + | return true | |
| 531 | + | } | |
| 532 | + | return i.customDiscoveryEnabled() | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | func (i *Interactive) customDiscoveryEnabled() bool { | |
| 536 | + | return i.cfg.RefreshCustomProviderModels != nil && i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured() | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | // refreshModelCatalogs runs each applicable refresh under its own 3 second | |
| 540 | + | // deadline, so one slow source cannot starve the other. Failures are joined; | |
| 541 | + | // the picker still opens with whatever catalog is available. | |
| 542 | + | func (i *Interactive) refreshModelCatalogs(ctx context.Context) error { | |
| 543 | + | var errs []error | |
| 544 | + | run := func(refresh func(context.Context) error) { | |
| 545 | + | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 546 | + | defer cancel() | |
| 547 | + | if err := refresh(refreshCtx); err != nil { | |
| 548 | + | errs = append(errs, err) | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 552 | + | run(i.cfg.RefreshLlamaCPPModels) | |
| 553 | + | } | |
| 554 | + | if i.customDiscoveryEnabled() { | |
| 555 | + | run(i.cfg.RefreshCustomProviderModels) | |
| 556 | + | } | |
| 557 | + | return errors.Join(errs...) | |
| 558 | + | } | |
| 559 | + | ||
| 516 | 560 | func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |
| 517 | 561 | var loggedIn []string | |
| 518 | 562 | if i.cfg.LoggedInProviders != nil { | |
| @@ -521,7 +565,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |||
| 521 | 565 | i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning) | |
| 522 | 566 | i.mu.Lock() | |
| 523 | 567 | if refreshErr != nil { | |
| 524 | - | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | |
| 568 | + | i.statusErr = "model refresh: " + refreshErr.Error() | |
| 525 | 569 | i.statusOK = "" | |
| 526 | 570 | } else if i.statusOK == "Refreshing models" { | |
| 527 | 571 | i.statusOK = "" | |
| @@ -805,20 +849,14 @@ func (i *Interactive) startOAuthFlow(provider string) { | |||
| 805 | 849 | i.dialog.ShowWaiting(loginURL) | |
| 806 | 850 | return | |
| 807 | 851 | } | |
| 808 | - | // Always run the manual/copy-code flow in parallel with the local | |
| 809 | - | // callback server so headless environments (docker, SSH) can paste | |
| 810 | - | // the authorization code directly without first pressing 'p'. | |
| 811 | - | _, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 852 | + | // One browser transaction: the callback server and the pasted-code path | |
| 853 | + | // share the same PKCE verifier and state inside the auth manager. | |
| 854 | + | loginURL, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 812 | 855 | if err != nil { | |
| 813 | 856 | i.dialog.ShowResult(false, err.Error()) | |
| 814 | 857 | return | |
| 815 | 858 | } | |
| 816 | - | manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider) | |
| 817 | - | if mErr == nil { | |
| 818 | - | i.dialog.ShowWaiting(manualURL) | |
| 819 | - | } else { | |
| 820 | - | i.dialog.ShowResult(false, mErr.Error()) | |
| 821 | - | } | |
| 859 | + | i.dialog.ShowWaiting(loginURL) | |
| 822 | 860 | } | |
| 823 | 861 | func (i *Interactive) startManualOAuthFlow(provider string) { | |
| 824 | 862 | if i.cfg.AuthManager == nil { | |
| @@ -830,7 +868,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) { | |||
| 830 | 868 | i.dialog.ShowResult(false, err.Error()) | |
| 831 | 869 | return | |
| 832 | 870 | } | |
| 833 | - | i.dialog.url = url | |
| 871 | + | i.dialog.ShowPasteCode(url) | |
| 834 | 872 | i.invalidate() | |
| 835 | 873 | } | |
| 836 | 874 | func (i *Interactive) submitManualOAuthCode(code string) { | |
| @@ -838,10 +876,14 @@ func (i *Interactive) submitManualOAuthCode(code string) { | |||
| 838 | 876 | return | |
| 839 | 877 | } | |
| 840 | 878 | go func() { | |
| 841 | - | if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil { | |
| 842 | - | i.dialog.ShowResult(false, err.Error()) | |
| 843 | - | i.invalidate() | |
| 879 | + | err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code) | |
| 880 | + | if err == nil || errors.Is(err, auth.ErrOAuthCanceled) { | |
| 881 | + | // A canceled or replaced login must not overwrite the dialog of | |
| 882 | + | // whatever login is current now. | |
| 883 | + | return | |
| 844 | 884 | } | |
| 885 | + | i.dialog.ShowResult(false, err.Error()) | |
| 886 | + | i.invalidate() | |
| 845 | 887 | }() | |
| 846 | 888 | } | |
| 847 | 889 | func (i *Interactive) cancelAndWaitForIdle() { | |
| @@ -910,6 +952,8 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||
| 910 | 952 | i.invalidate() | |
| 911 | 953 | return | |
| 912 | 954 | } | |
| 955 | + | // A typed /skill:name command is a genuine user prompt. | |
| 956 | + | prompt = i.skillPins.consume(prompt) | |
| 913 | 957 | if i.busy { | |
| 914 | 958 | // Keep the mutex held while enqueueing so the turn-completion | |
| 915 | 959 | // goroutine cannot publish idle and inspect an empty queue between | |
| @@ -934,11 +978,7 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||
| 934 | 978 | i.startTurn(ctx, prompt) | |
| 935 | 979 | } | |
| 936 | 980 | func (i *Interactive) openSkillsDialog() { | |
| 937 | - | var list []*skills.Skill | |
| 938 | - | if i.cfg.SkillSnapshot != nil { | |
| 939 | - | list = i.cfg.SkillSnapshot() | |
| 940 | - | } | |
| 941 | - | i.skillsDialog.Open(list) | |
| 981 | + | i.openSkillsDialogWithPins() | |
| 942 | 982 | i.invalidate() | |
| 943 | 983 | } | |
| 944 | 984 | func (i *Interactive) openJumpDialog(args []string) { | |
| @@ -1204,6 +1244,16 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||
| 1204 | 1244 | i.mu.Lock() | |
| 1205 | 1245 | i.cfg.Model = m.ID | |
| 1206 | 1246 | i.agent.Model = m.ID | |
| 1247 | + | // Output budget and context capacity are per model; keeping the | |
| 1248 | + | // previous model's values can truncate responses or mis-size | |
| 1249 | + | // compaction. Router clients also retain the selected metadata. | |
| 1250 | + | if setter, ok := i.agent.Client.(provider.ModelMetadataSetter); ok { | |
| 1251 | + | setter.SetModelMetadata(m) | |
| 1252 | + | } | |
| 1253 | + | i.agent.MaxTokens = m.MaxOutput | |
| 1254 | + | if m.ContextWindow > 0 { | |
| 1255 | + | i.agent.ContextWindow = m.ContextWindow | |
| 1256 | + | } | |
| 1207 | 1257 | i.statusOK = "model: " + m.ID | |
| 1208 | 1258 | i.statusErr = "" | |
| 1209 | 1259 | i.mu.Unlock() | |
| @@ -1288,6 +1338,10 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||
| 1288 | 1338 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | |
| 1289 | 1339 | switch ev.Kind { | |
| 1290 | 1340 | case "started": | |
| 1341 | + | // The manual flow already shows its own URL in the paste-code step. | |
| 1342 | + | if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL { | |
| 1343 | + | return | |
| 1344 | + | } | |
| 1291 | 1345 | i.dialog.ShowWaiting(ev.URL) | |
| 1292 | 1346 | case "browser_open": | |
| 1293 | 1347 | // no-op | |
M packages/agent/modes/interactive_compaction.go +2 -0
| @@ -325,12 +325,14 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 325 | 325 | i.statusOK = "compaction cancelled" | |
| 326 | 326 | } | |
| 327 | 327 | i.discardQueuedMessagesLocked(false) | |
| 328 | + | i.skillPins.recall(i.pendingCompactPrompt) | |
| 328 | 329 | i.clearPendingCompactTurnLocked() | |
| 329 | 330 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 330 | 331 | case err != nil: | |
| 331 | 332 | i.statusErr = "compaction failed: " + err.Error() | |
| 332 | 333 | i.statusOK = "" | |
| 333 | 334 | i.discardQueuedMessagesLocked(true) | |
| 335 | + | i.skillPins.recall(i.pendingCompactPrompt) | |
| 334 | 336 | i.clearPendingCompactTurnLocked() | |
| 335 | 337 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 336 | 338 | default: | |
M packages/agent/modes/interactive_host.go +19 -0
| @@ -103,6 +103,8 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||
| 103 | 103 | i.prepareReplacementAgentLocked(ag) | |
| 104 | 104 | i.compactContinuation = decodeCompactHandoff(compactHandoff) | |
| 105 | 105 | i.agent = ag | |
| 106 | + | // Resumed, imported and forked conversations already have context. | |
| 107 | + | i.skillPins.disarm() | |
| 106 | 108 | i.cfg.Provider = providerName | |
| 107 | 109 | i.cfg.Model = model | |
| 108 | 110 | i.cfg.detachMismatchedModelProfile() | |
| @@ -169,6 +171,12 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 169 | 171 | profiles, _ := subagents.Discover(cwd, home) | |
| 170 | 172 | subagentsAddendum := subagents.SystemPromptAddendum(profiles) | |
| 171 | 173 | ||
| 174 | + | // The new cwd starts a fresh conversation: discover its skills and load its | |
| 175 | + | // pins before taking any lock. The host has already switched its own cwd. | |
| 176 | + | i.skillPins.ioMu.Lock() | |
| 177 | + | defer i.skillPins.ioMu.Unlock() | |
| 178 | + | pinFetch := i.skillPins.fetch(cwd, true) | |
| 179 | + | ||
| 172 | 180 | i.agentMu.Lock() | |
| 173 | 181 | defer i.agentMu.Unlock() | |
| 174 | 182 | i.mu.Lock() | |
| @@ -180,6 +188,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 180 | 188 | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked()) | |
| 181 | 189 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | |
| 182 | 190 | i.view.StartupContextPaths = nil | |
| 191 | + | i.armSkillPinsLocked(pinFetch) | |
| 183 | 192 | i.view.InvalidateRenderCache() | |
| 184 | 193 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | |
| 185 | 194 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | |
| @@ -306,6 +315,11 @@ func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput | |||
| 306 | 315 | return | |
| 307 | 316 | } | |
| 308 | 317 | i.maybeStartSessionTitle(i.runCtx, text) | |
| 318 | + | if userInput { | |
| 319 | + | // Only genuine user input carries pinned skills. Host evidence and | |
| 320 | + | // scheduler/extension prompts (userInput=false) never consume them. | |
| 321 | + | message.Text = i.consumePinnedSkills(message.Text) | |
| 322 | + | } | |
| 309 | 323 | i.mu.Lock() | |
| 310 | 324 | if i.busy { | |
| 311 | 325 | // Keep the interactive mutex held through Agent.QueueMessage so turn teardown | |
| @@ -365,6 +379,11 @@ func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage { | |||
| 365 | 379 | ||
| 366 | 380 | func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) { | |
| 367 | 381 | pending := i.takeQueuedMessagesLocked() | |
| 382 | + | for _, message := range pending { | |
| 383 | + | if !message.HostEvent { | |
| 384 | + | i.skillPins.recall(message.Text) | |
| 385 | + | } | |
| 386 | + | } | |
| 368 | 387 | if !preserveHostEvents { | |
| 369 | 388 | return | |
| 370 | 389 | } | |
M packages/agent/modes/interactive_input_routing.go +26 -6
| @@ -73,6 +73,7 @@ func (i *Interactive) confirmChildActiveLocked() bool { | |||
| 73 | 73 | i.settingsDialog.Active() || | |
| 74 | 74 | i.sessionOpsDialog.Active() || | |
| 75 | 75 | i.sessionTreeDialog.Active() || | |
| 76 | + | i.timeline.Active() || | |
| 76 | 77 | i.extPanel.Active() | |
| 77 | 78 | } | |
| 78 | 79 | func (i *Interactive) restoreConfirmFocus() { | |
| @@ -135,6 +136,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 135 | 136 | return false | |
| 136 | 137 | } | |
| 137 | 138 | act := i.dialog.HandleKey(k) | |
| 139 | + | if act.Close && i.cfg.AuthManager != nil { | |
| 140 | + | i.cfg.AuthManager.CancelOAuth() | |
| 141 | + | } | |
| 138 | 142 | if act.StartAPIKey { | |
| 139 | 143 | i.startAPIKeyFlow(act.Provider) | |
| 140 | 144 | } | |
| @@ -377,6 +381,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 377 | 381 | i.invalidate() | |
| 378 | 382 | return false | |
| 379 | 383 | } | |
| 384 | + | if i.timeline.Active() { | |
| 385 | + | i.handleTimelineKey(k) | |
| 386 | + | return false | |
| 387 | + | } | |
| 380 | 388 | if i.sessionOpsDialog.Active() { | |
| 381 | 389 | if k.Kind == tui.KeyCtrlC { | |
| 382 | 390 | i.sessionOpsDialog.Close() | |
| @@ -464,6 +472,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 464 | 472 | i.invalidate() | |
| 465 | 473 | return false | |
| 466 | 474 | } | |
| 475 | + | if i.handleSkillPinKey(k) { | |
| 476 | + | i.invalidate() | |
| 477 | + | return false | |
| 478 | + | } | |
| 467 | 479 | i.skillsDialog.HandleKey(k) | |
| 468 | 480 | i.invalidate() | |
| 469 | 481 | return false | |
| @@ -558,11 +570,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 558 | 570 | i.ed.Clear() | |
| 559 | 571 | i.clipboardImages = nil | |
| 560 | 572 | i.suggest.Reset() | |
| 561 | - | if ag != nil { | |
| 562 | - | ag.DrainQueuedMessages() | |
| 563 | - | } | |
| 564 | 573 | i.mu.Lock() | |
| 565 | - | i.queued = nil | |
| 574 | + | i.discardQueuedMessagesLocked(false) | |
| 566 | 575 | i.statusOK = "input cleared" | |
| 567 | 576 | i.statusErr = "" | |
| 568 | 577 | i.mu.Unlock() | |
| @@ -922,6 +931,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 922 | 931 | go i.telegramBridge.OnUserTyped(text) | |
| 923 | 932 | } | |
| 924 | 933 | i.maybeStartSessionTitle(ctx, text) | |
| 934 | + | // This is the explicit user boundary for pinned skills: the first | |
| 935 | + | // genuine prompt carries them whether it starts a turn or is queued | |
| 936 | + | // behind a startup command, shell escape, or running turn. | |
| 937 | + | text = i.consumePinnedSkills(text) | |
| 925 | 938 | // If a turn is already in flight, queue this prompt inside the | |
| 926 | 939 | // agent loop so it is delivered at the next safe model-call | |
| 927 | 940 | // boundary instead of waiting for the whole run to finish. | |
| @@ -1034,16 +1047,23 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |||
| 1034 | 1047 | return true | |
| 1035 | 1048 | } | |
| 1036 | 1049 | func (i *Interactive) inputHistory() []string { | |
| 1037 | - | if i.agent == nil { | |
| 1050 | + | i.mu.Lock() | |
| 1051 | + | agent := i.agent | |
| 1052 | + | expanded, original := i.skillPins.expanded, i.skillPins.original | |
| 1053 | + | i.mu.Unlock() | |
| 1054 | + | if agent == nil { | |
| 1038 | 1055 | return nil | |
| 1039 | 1056 | } | |
| 1040 | - | msgs := i.agent.Messages() | |
| 1057 | + | msgs := agent.Messages() | |
| 1041 | 1058 | hist := make([]string, 0, len(msgs)) | |
| 1042 | 1059 | for _, m := range msgs { | |
| 1043 | 1060 | if !isForkableUserMessage(m) { | |
| 1044 | 1061 | continue | |
| 1045 | 1062 | } | |
| 1046 | 1063 | text := userMessageText(m) | |
| 1064 | + | if expanded != "" && text == expanded { | |
| 1065 | + | text = original | |
| 1066 | + | } | |
| 1047 | 1067 | if strings.TrimSpace(text) == "" { | |
| 1048 | 1068 | continue | |
| 1049 | 1069 | } | |
A packages/agent/modes/interactive_model_refresh_test.go +136 -0
| @@ -0,0 +1,136 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func awaitModelRefresh(t *testing.T, i *Interactive) modelRefreshResult { | |
| 12 | + | t.Helper() | |
| 13 | + | select { | |
| 14 | + | case result := <-i.modelRefresh: | |
| 15 | + | return result | |
| 16 | + | case <-time.After(5 * time.Second): | |
| 17 | + | t.Fatal("refresh result never delivered") | |
| 18 | + | } | |
| 19 | + | return modelRefreshResult{} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestModelPickerRefreshesCustomDiscoveryWithoutLlama(t *testing.T) { | |
| 23 | + | calls := make(chan struct{}, 2) | |
| 24 | + | i := &Interactive{ | |
| 25 | + | cfg: InteractiveConfig{ | |
| 26 | + | CustomDiscoveryConfigured: func() bool { return true }, | |
| 27 | + | RefreshCustomProviderModels: func(ctx context.Context) error { | |
| 28 | + | if _, ok := ctx.Deadline(); !ok { | |
| 29 | + | t.Error("custom refresh has no deadline") | |
| 30 | + | } | |
| 31 | + | calls <- struct{}{} | |
| 32 | + | return nil | |
| 33 | + | }, | |
| 34 | + | RefreshLlamaCPPModels: func(context.Context) error { t.Error("llama refreshed while unconfigured"); return nil }, | |
| 35 | + | }, | |
| 36 | + | modelRefresh: make(chan modelRefreshResult, 1), | |
| 37 | + | modelDialog: newModelDialog(), | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | i.runSlash(context.Background(), "/model") | |
| 41 | + | if i.modelDialog.Active() || i.statusOK != "Refreshing models" || !i.modelRefreshing { | |
| 42 | + | t.Fatalf("picker opened early or status wrong: active=%v status=%q", i.modelDialog.Active(), i.statusOK) | |
| 43 | + | } | |
| 44 | + | result := awaitModelRefresh(t, i) | |
| 45 | + | <-calls | |
| 46 | + | i.openModelPickerAfterRefresh(result.err) | |
| 47 | + | if !i.modelDialog.Active() { | |
| 48 | + | t.Fatal("picker did not open after refresh") | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestModelPickerCustomDiscoveryOptOutOpensImmediately(t *testing.T) { | |
| 53 | + | i := &Interactive{ | |
| 54 | + | cfg: InteractiveConfig{ | |
| 55 | + | CustomDiscoveryConfigured: func() bool { return false }, | |
| 56 | + | RefreshCustomProviderModels: func(context.Context) error { t.Error("unexpected refresh"); return nil }, | |
| 57 | + | }, | |
| 58 | + | modelRefresh: make(chan modelRefreshResult, 1), | |
| 59 | + | modelDialog: newModelDialog(), | |
| 60 | + | } | |
| 61 | + | i.runSlash(context.Background(), "/model") | |
| 62 | + | if !i.modelDialog.Active() || i.modelRefreshing { | |
| 63 | + | t.Fatal("picker did not open immediately without opt-in") | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestModelPickerRunsLlamaAndCustomIndependentlyAndJoinsErrors(t *testing.T) { | |
| 68 | + | var order []string | |
| 69 | + | llamaErr, customErr := errors.New("llama down"), errors.New("custom down") | |
| 70 | + | i := &Interactive{ | |
| 71 | + | llamaConfigured: true, | |
| 72 | + | cfg: InteractiveConfig{ | |
| 73 | + | RefreshLlamaCPPModels: func(ctx context.Context) error { | |
| 74 | + | order = append(order, "llama") | |
| 75 | + | return llamaErr | |
| 76 | + | }, | |
| 77 | + | CustomDiscoveryConfigured: func() bool { return true }, | |
| 78 | + | RefreshCustomProviderModels: func(ctx context.Context) error { | |
| 79 | + | // A failed sibling must not consume this refresh's own context. | |
| 80 | + | if ctx.Err() != nil { | |
| 81 | + | t.Errorf("custom refresh context already done: %v", ctx.Err()) | |
| 82 | + | } | |
| 83 | + | order = append(order, "custom") | |
| 84 | + | return customErr | |
| 85 | + | }, | |
| 86 | + | }, | |
| 87 | + | modelRefresh: make(chan modelRefreshResult, 1), | |
| 88 | + | modelDialog: newModelDialog(), | |
| 89 | + | } | |
| 90 | + | i.runSlash(context.Background(), "/model") | |
| 91 | + | result := awaitModelRefresh(t, i) | |
| 92 | + | if strings.Join(order, ",") != "llama,custom" { | |
| 93 | + | t.Fatalf("refresh order = %v", order) | |
| 94 | + | } | |
| 95 | + | if !errors.Is(result.err, llamaErr) || !errors.Is(result.err, customErr) { | |
| 96 | + | t.Fatalf("errors not joined: %v", result.err) | |
| 97 | + | } | |
| 98 | + | i.openModelPickerAfterRefresh(result.err) | |
| 99 | + | if !i.modelDialog.Active() { | |
| 100 | + | t.Fatal("picker unusable after refresh errors") | |
| 101 | + | } | |
| 102 | + | if !strings.HasPrefix(i.statusErr, "model refresh: ") || !strings.Contains(i.statusErr, "llama down") || !strings.Contains(i.statusErr, "custom down") { | |
| 103 | + | t.Fatalf("status = %q", i.statusErr) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestModelPickerRefreshDoesNotLeakWhenContextCancelled(t *testing.T) { | |
| 108 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 109 | + | done := make(chan struct{}) | |
| 110 | + | i := &Interactive{ | |
| 111 | + | cfg: InteractiveConfig{ | |
| 112 | + | CustomDiscoveryConfigured: func() bool { return true }, | |
| 113 | + | RefreshCustomProviderModels: func(ctx context.Context) error { | |
| 114 | + | cancel() | |
| 115 | + | close(done) | |
| 116 | + | return nil | |
| 117 | + | }, | |
| 118 | + | }, | |
| 119 | + | // Unbuffered and never read: delivery must give up on ctx.Done. | |
| 120 | + | modelRefresh: make(chan modelRefreshResult), | |
| 121 | + | modelDialog: newModelDialog(), | |
| 122 | + | } | |
| 123 | + | i.runSlash(ctx, "/model") | |
| 124 | + | select { | |
| 125 | + | case <-done: | |
| 126 | + | case <-time.After(5 * time.Second): | |
| 127 | + | t.Fatal("refresh not invoked") | |
| 128 | + | } | |
| 129 | + | // The goroutine must exit without a receiver; a leaked sender would keep | |
| 130 | + | // this send from ever being received by a fresh reader. | |
| 131 | + | select { | |
| 132 | + | case r := <-i.modelRefresh: | |
| 133 | + | t.Fatalf("result delivered after cancellation: %+v", r) | |
| 134 | + | case <-time.After(100 * time.Millisecond): | |
| 135 | + | } | |
| 136 | + | } | |
A packages/agent/modes/interactive_model_swap_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/core" | |
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type metadataRecordingClient struct { | |
| 11 | + | provider.Client | |
| 12 | + | got []provider.Model | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func (c *metadataRecordingClient) SetModelMetadata(m provider.Model) { c.got = append(c.got, m) } | |
| 16 | + | ||
| 17 | + | // A same-provider /model switch reuses the agent, so it must also adopt the | |
| 18 | + | // new model's output budget and context window. Keeping the previous model's | |
| 19 | + | // MaxTokens truncated responses (e.g. a 4096 budget carried onto a 128k model). | |
| 20 | + | func TestSameProviderModelSwapUpdatesModelLimits(t *testing.T) { | |
| 21 | + | const from, to = "claude-3-5-haiku-20241022", "claude-opus-4-1-20250805" | |
| 22 | + | fromModel, err := provider.FindModel("anthropic", from) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | toModel, err := provider.FindModel("anthropic", to) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if fromModel.MaxOutput == toModel.MaxOutput || toModel.MaxOutput <= 0 { | |
| 31 | + | t.Fatalf("test needs distinct positive limits, got %d and %d", fromModel.MaxOutput, toModel.MaxOutput) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | client := &metadataRecordingClient{} | |
| 35 | + | ag := core.NewAgent(client, from, "", nil) | |
| 36 | + | ag.MaxTokens = fromModel.MaxOutput | |
| 37 | + | ag.ContextWindow = fromModel.ContextWindow | |
| 38 | + | i := &Interactive{agent: ag, cfg: InteractiveConfig{Provider: "anthropic", Model: from}} | |
| 39 | + | ||
| 40 | + | i.swapModel("anthropic", to, nil, false) | |
| 41 | + | ||
| 42 | + | if ag.Model != to { | |
| 43 | + | t.Fatalf("agent model = %q, want %q", ag.Model, to) | |
| 44 | + | } | |
| 45 | + | if ag.MaxTokens != toModel.MaxOutput { | |
| 46 | + | t.Fatalf("agent MaxTokens = %d, want %d", ag.MaxTokens, toModel.MaxOutput) | |
| 47 | + | } | |
| 48 | + | if ag.ContextWindow != toModel.ContextWindow { | |
| 49 | + | t.Fatalf("agent ContextWindow = %d, want %d", ag.ContextWindow, toModel.ContextWindow) | |
| 50 | + | } | |
| 51 | + | if len(client.got) != 1 || client.got[0].ID != to || client.got[0].MaxOutput != toModel.MaxOutput { | |
| 52 | + | t.Fatalf("client metadata = %+v, want %s", client.got, to) | |
| 53 | + | } | |
| 54 | + | } | |
M packages/agent/modes/interactive_rendering.go +9 -1
| @@ -530,6 +530,9 @@ func (i *Interactive) redraw() { | |||
| 530 | 530 | dialogID = "session-tree" | |
| 531 | 531 | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | |
| 532 | 532 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth) | |
| 533 | + | case i.timeline.Active(): | |
| 534 | + | dialogID = "timeline" | |
| 535 | + | dialog = i.timeline.Render(i.cfg.Theme, dialogWidth, paneMax.ContentHeight(), i.timelineDataLocked()) | |
| 533 | 536 | } | |
| 534 | 537 | dialogTitle := floatingOverlayTitle(dialogID) | |
| 535 | 538 | dialogRemovedTopRows := 0 | |
| @@ -742,10 +745,15 @@ func (i *Interactive) redraw() { | |||
| 742 | 745 | // content. The status block and editor get their own dedicated | |
| 743 | 746 | // blanks so spacing stays consistent whether or not a dialog or | |
| 744 | 747 | // popup is showing. | |
| 748 | + | var pinnedSkillLines []string | |
| 749 | + | if len(dialog) == 0 && len(suggest) == 0 { | |
| 750 | + | pinnedSkillLines = i.skillPins.notice(i.cfg.Theme, mainCols) | |
| 751 | + | } | |
| 745 | 752 | composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) { | |
| 746 | - | bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) | |
| 753 | + | bottom = make([]string, 0, len(suggest)+len(queue)+len(pinnedSkillLines)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) | |
| 747 | 754 | bottom = append(bottom, suggest...) | |
| 748 | 755 | bottom = append(bottom, queue...) | |
| 756 | + | bottom = append(bottom, pinnedSkillLines...) | |
| 749 | 757 | lineInput := inputStyle == tui.InputStyleLines | |
| 750 | 758 | statusBelow := statusPosition == tui.StatusPositionBelowInput | |
| 751 | 759 | workingBelow := workingPosition == tui.WorkingPositionBelowInput | |
M packages/agent/modes/interactive_resident_completion_test.go +14 -19
| @@ -18,7 +18,11 @@ func TestResidentCompletionBatchDoesNotReportTerminalSiblingAsRunning(t *testing | |||
| 18 | 18 | ctx, cancel := context.WithCancel(context.Background()) | |
| 19 | 19 | defer cancel() | |
| 20 | 20 | ag := core.NewAgent(nil, "model", "", nil) | |
| 21 | - | i := &Interactive{agent: ag, busy: true, runCtx: ctx} | |
| 21 | + | // Own the delivery loop during setup: TrackResidentSubagent requests | |
| 22 | + | // delivery immediately, and a manager-wave hold does not stop reports | |
| 23 | + | // sliding into a busy parent. Both outcomes must be ready before draining | |
| 24 | + | // to exercise the same-batch sibling filter, not two valid separate batches. | |
| 25 | + | i := &Interactive{agent: ag, busy: true, runCtx: ctx, completionDeliveryRunning: true} | |
| 22 | 26 | release := i.beginCompletionDeliveryHold() | |
| 23 | 27 | defer release() | |
| 24 | 28 | i.TrackResidentSubagent("first", "turn-1") | |
| @@ -27,24 +31,15 @@ func TestResidentCompletionBatchDoesNotReportTerminalSiblingAsRunning(t *testing | |||
| 27 | 31 | tracker := i.ensureCompletionTracker() | |
| 28 | 32 | tracker.Report(subagents.Completion{AgentID: "first", TurnID: "turn-1", Status: "completed"}) | |
| 29 | 33 | tracker.Report(subagents.Completion{AgentID: "second", TurnID: "turn-2", Status: "completed"}) | |
| 30 | - | i.requestCompletionDelivery() | |
| 31 | - | ||
| 32 | - | deadline := time.NewTimer(2 * time.Second) | |
| 33 | - | defer deadline.Stop() | |
| 34 | - | for { | |
| 35 | - | queued := ag.PendingQueuedMessages() | |
| 36 | - | if len(queued) == 2 { | |
| 37 | - | for _, message := range queued { | |
| 38 | - | if strings.Contains(message.Text, "Still running:") { | |
| 39 | - | t.Fatalf("terminal sibling reported as running: %q", message.Text) | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | return | |
| 43 | - | } | |
| 44 | - | select { | |
| 45 | - | case <-deadline.C: | |
| 46 | - | t.Fatalf("queued completions = %#v, want 2", queued) | |
| 47 | - | case <-time.After(time.Millisecond): | |
| 34 | + | i.deliverCompletionUpdates() | |
| 35 | + | ||
| 36 | + | queued := ag.PendingQueuedMessages() | |
| 37 | + | if len(queued) != 2 { | |
| 38 | + | t.Fatalf("queued completions = %#v, want 2", queued) | |
| 39 | + | } | |
| 40 | + | for _, message := range queued { | |
| 41 | + | if strings.Contains(message.Text, "Still running:") { | |
| 42 | + | t.Fatalf("terminal sibling reported as running: %q", message.Text) | |
| 48 | 43 | } | |
| 49 | 44 | } | |
| 50 | 45 | } | |
M packages/agent/modes/interactive_resident_subagents.go +4 -1
| @@ -689,6 +689,7 @@ func (h *telegramHost) Notify(level, message string) { | |||
| 689 | 689 | } | |
| 690 | 690 | func (i *Interactive) openSessionOpsDialog() { | |
| 691 | 691 | items := []sessionOpsItem{ | |
| 692 | + | {label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"}, | |
| 692 | 693 | {label: "export", action: "export", hint: "write the current session to a .zutsession file"}, | |
| 693 | 694 | {label: "import", action: "import", hint: "load a .zutsession file into this directory"}, | |
| 694 | 695 | {label: "fork", action: "fork", hint: "branch from a past user message into a new session"}, | |
| @@ -699,6 +700,8 @@ func (i *Interactive) openSessionOpsDialog() { | |||
| 699 | 700 | } | |
| 700 | 701 | func (i *Interactive) doSessionOp(action, arg string) { | |
| 701 | 702 | switch action { | |
| 703 | + | case "timeline": | |
| 704 | + | i.openTimeline() | |
| 702 | 705 | case "export": | |
| 703 | 706 | i.doSessionExport(arg) | |
| 704 | 707 | case "import": | |
| @@ -709,7 +712,7 @@ func (i *Interactive) doSessionOp(action, arg string) { | |||
| 709 | 712 | i.doSessionTree() | |
| 710 | 713 | default: | |
| 711 | 714 | i.mu.Lock() | |
| 712 | - | i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)" | |
| 715 | + | i.statusErr = "unknown /session action: " + action + " (use timeline, export, import, fork, or tree)" | |
| 713 | 716 | i.mu.Unlock() | |
| 714 | 717 | i.invalidate() | |
| 715 | 718 | } | |
M packages/agent/modes/interactive_submit.go +6 -0
| @@ -17,6 +17,9 @@ func (i *Interactive) Submit(text string) { | |||
| 17 | 17 | i.mu.Unlock() | |
| 18 | 18 | if !awaitingStartupPre { | |
| 19 | 19 | i.maybeStartSessionTitle(parent, text) | |
| 20 | + | // Initial and deferred input are user prompts; the startup command | |
| 21 | + | // itself is not and leaves the pins pending. | |
| 22 | + | text = i.consumePinnedSkills(text) | |
| 20 | 23 | } | |
| 21 | 24 | i.startTurn(parent, text) | |
| 22 | 25 | } | |
| @@ -48,6 +51,9 @@ func (i *Interactive) completeStartupPre() { | |||
| 48 | 51 | // single-threaded. Input entered while resources were reloading wins over the | |
| 49 | 52 | // deferred prefill rather than being overwritten. | |
| 50 | 53 | func (i *Interactive) applyStartupPreResult(result startupPreResult) { | |
| 54 | + | // entry.pre may have added or removed skills; re-resolve the pending pins | |
| 55 | + | // without holding the interactive mutex. | |
| 56 | + | i.refreshPendingSkillPins() | |
| 51 | 57 | if result.deferred == "" { | |
| 52 | 58 | return | |
| 53 | 59 | } | |
M packages/agent/modes/llama_dialog.go +1 -1
| @@ -130,7 +130,7 @@ func (d *llamaDialog) refresh(next llamaDialogStep, message string) { | |||
| 130 | 130 | } | |
| 131 | 131 | return models[i].ID < models[j].ID | |
| 132 | 132 | }) | |
| 133 | - | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | |
| 133 | + | provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL)) | |
| 134 | 134 | d.mu.Lock() | |
| 135 | 135 | if d.step != llamaClosed { | |
| 136 | 136 | d.models = models | |
M packages/agent/modes/login_dialog.go +60 -9
| @@ -18,15 +18,16 @@ type loginStep int | |||
| 18 | 18 | // dialog must default to closed so nothing shows up until Open() is | |
| 19 | 19 | // explicitly called. | |
| 20 | 20 | const ( | |
| 21 | - | loginStepClosed loginStep = iota | |
| 22 | - | loginStepMethod // pick apikey vs subscription | |
| 23 | - | loginStepProvider // pick anthropic vs openai vs kimi | |
| 24 | - | loginStepLlamaURL // enter and validate llama.cpp router URL | |
| 25 | - | loginStepLlamaKey // enter optional llama.cpp API key | |
| 26 | - | loginStepWaiting // browser open, waiting for callback | |
| 27 | - | loginStepPasteCode // user pastes the auth code here | |
| 28 | - | loginStepInfo // informational setup guidance | |
| 29 | - | loginStepDone // success or error, waiting for key to dismiss | |
| 21 | + | loginStepClosed loginStep = iota | |
| 22 | + | loginStepMethod // pick apikey vs subscription | |
| 23 | + | loginStepProvider // pick anthropic vs openai vs kimi | |
| 24 | + | loginStepOAuthMethod // pick browser or copy-code for Anthropic | |
| 25 | + | loginStepLlamaURL // enter and validate llama.cpp router URL | |
| 26 | + | loginStepLlamaKey // enter optional llama.cpp API key | |
| 27 | + | loginStepWaiting // browser open, waiting for callback | |
| 28 | + | loginStepPasteCode // user pastes the auth code here | |
| 29 | + | loginStepInfo // informational setup guidance | |
| 30 | + | loginStepDone // success or error, waiting for key to dismiss | |
| 30 | 31 | ) | |
| 31 | 32 | ||
| 32 | 33 | const loginProviderPageSize = 8 | |
| @@ -79,6 +80,7 @@ func (d *loginDialog) Open(zutHome string) { | |||
| 79 | 80 | d.url = "" | |
| 80 | 81 | d.cursor = 0 | |
| 81 | 82 | d.providerQuery = "" | |
| 83 | + | d.codeEd = nil | |
| 82 | 84 | d.llamaEd = nil | |
| 83 | 85 | d.llamaURL = "" | |
| 84 | 86 | d.infoTitle = "" | |
| @@ -179,6 +181,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 179 | 181 | lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | |
| 180 | 182 | } | |
| 181 | 183 | lines = append(lines, frameRule(th, width)) | |
| 184 | + | case loginStepOAuthMethod: | |
| 185 | + | lines = append(lines, frameHeader(th, "login - subscription - "+providerLabel(d.provider), width)) | |
| 186 | + | lines = append(lines, th.FGColor(th.Muted, "choose sign-in flow (↑/↓, enter, esc to go back):")) | |
| 187 | + | for idx, label := range []string{"browser callback", "copy code (headless / SSH)"} { | |
| 188 | + | plain := " " + label | |
| 189 | + | if idx == d.cursor { | |
| 190 | + | lines = append(lines, th.PadHighlight(plain, width)) | |
| 191 | + | } else { | |
| 192 | + | lines = append(lines, th.FGColor(th.Muted, plain)) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | lines = append(lines, frameRule(th, width)) | |
| 182 | 196 | case loginStepLlamaURL: | |
| 183 | 197 | lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width)) | |
| 184 | 198 | lines = append(lines, th.FGColor(th.Muted, "llama.cpp router URL:")) | |
| @@ -438,6 +452,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { | |||
| 438 | 452 | return d.handleMethodKey(k) | |
| 439 | 453 | case loginStepProvider: | |
| 440 | 454 | return d.handleProviderKey(k) | |
| 455 | + | case loginStepOAuthMethod: | |
| 456 | + | return d.handleOAuthMethodKey(k) | |
| 441 | 457 | case loginStepLlamaURL, loginStepLlamaKey: | |
| 442 | 458 | return d.handleLlamaKey(k) | |
| 443 | 459 | case loginStepWaiting: | |
| @@ -534,6 +550,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 534 | 550 | d.message = "" | |
| 535 | 551 | return loginDialogAction{} | |
| 536 | 552 | } | |
| 553 | + | if d.method == "oauth" && d.provider == "anthropic" { | |
| 554 | + | d.step = loginStepOAuthMethod | |
| 555 | + | d.cursor = 0 | |
| 556 | + | return loginDialogAction{} | |
| 557 | + | } | |
| 537 | 558 | d.step = loginStepWaiting | |
| 538 | 559 | if d.method == "apikey" { | |
| 539 | 560 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | |
| @@ -543,6 +564,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 543 | 564 | return loginDialogAction{} | |
| 544 | 565 | } | |
| 545 | 566 | ||
| 567 | + | func (d *loginDialog) handleOAuthMethodKey(k tui.Key) loginDialogAction { | |
| 568 | + | switch k.Kind { | |
| 569 | + | case tui.KeyUp: | |
| 570 | + | d.cursor = 0 | |
| 571 | + | case tui.KeyDown: | |
| 572 | + | d.cursor = 1 | |
| 573 | + | case tui.KeyEsc: | |
| 574 | + | d.step = loginStepProvider | |
| 575 | + | d.cursor = 0 | |
| 576 | + | case tui.KeyEnter: | |
| 577 | + | if d.cursor == 1 { | |
| 578 | + | d.step = loginStepPasteCode | |
| 579 | + | return loginDialogAction{StartManual: true, Provider: d.provider} | |
| 580 | + | } | |
| 581 | + | d.step = loginStepWaiting | |
| 582 | + | return loginDialogAction{StartOAuth: true, Provider: d.provider} | |
| 583 | + | } | |
| 584 | + | return loginDialogAction{} | |
| 585 | + | } | |
| 586 | + | ||
| 546 | 587 | func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | |
| 547 | 588 | if k.Kind == tui.KeyEsc { | |
| 548 | 589 | d.Close() | |
| @@ -584,6 +625,16 @@ func (d *loginDialog) ShowWaiting(url string) { | |||
| 584 | 625 | d.url = url | |
| 585 | 626 | } | |
| 586 | 627 | ||
| 628 | + | // ShowPasteCode displays the URL for a manual OAuth transaction. | |
| 629 | + | // No-op if the user has already dismissed the dialog. | |
| 630 | + | func (d *loginDialog) ShowPasteCode(url string) { | |
| 631 | + | if d.step == loginStepClosed { | |
| 632 | + | return | |
| 633 | + | } | |
| 634 | + | d.step = loginStepPasteCode | |
| 635 | + | d.url = url | |
| 636 | + | } | |
| 637 | + | ||
| 587 | 638 | // ShowInfo transitions to an informational setup dialog. | |
| 588 | 639 | // No-op if the user has already dismissed the dialog. | |
| 589 | 640 | func (d *loginDialog) ShowInfo(title string, lines []string) { | |
M packages/agent/modes/login_dialog_test.go +104 -0
| @@ -2,11 +2,13 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "net/url" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| 11 | + | "github.com/bnema/zut/packages/provider/auth" | |
| 10 | 12 | "github.com/bnema/zut/packages/tui" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| @@ -168,3 +170,105 @@ func TestInteractiveAltCUsesTerminalClipboardBeforeSystemClipboard(t *testing.T) | |||
| 168 | 170 | t.Fatalf("system clipboard helper ran: %v", err) | |
| 169 | 171 | } | |
| 170 | 172 | } | |
| 173 | + | ||
| 174 | + | func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) { | |
| 175 | + | d := newLoginDialog() | |
| 176 | + | d.Open(t.TempDir()) | |
| 177 | + | d.method = "oauth" | |
| 178 | + | d.provider = "openai-codex" | |
| 179 | + | d.ShowWaiting("http://localhost:1455/auth/callback") | |
| 180 | + | ||
| 181 | + | text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n")) | |
| 182 | + | if !strings.Contains(text, "paste the authorization code") { | |
| 183 | + | t.Fatal("OpenAI OAuth dialog does not offer pasted code entry") | |
| 184 | + | } | |
| 185 | + | d.codeEd.SetValue("code#state") | |
| 186 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SubmitCode != "code#state" { | |
| 187 | + | t.Fatalf("code submission = %+v", action) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) { | |
| 192 | + | for _, tc := range []struct { | |
| 193 | + | name string | |
| 194 | + | choice int | |
| 195 | + | manual bool | |
| 196 | + | step loginStep | |
| 197 | + | }{ | |
| 198 | + | {name: "browser", choice: 0, step: loginStepWaiting}, | |
| 199 | + | {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode}, | |
| 200 | + | } { | |
| 201 | + | t.Run(tc.name, func(t *testing.T) { | |
| 202 | + | d := newLoginDialog() | |
| 203 | + | d.Open(t.TempDir()) | |
| 204 | + | d.method = "oauth" | |
| 205 | + | d.step = loginStepProvider | |
| 206 | + | for idx, p := range d.providerOptions() { | |
| 207 | + | if p == "anthropic" { | |
| 208 | + | d.cursor = idx | |
| 209 | + | break | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod { | |
| 213 | + | t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step) | |
| 214 | + | } | |
| 215 | + | for i := 0; i < tc.choice; i++ { | |
| 216 | + | d.HandleKey(tui.Key{Kind: tui.KeyDown}) | |
| 217 | + | } | |
| 218 | + | action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 219 | + | if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step { | |
| 220 | + | t.Fatalf("flow action=%+v step=%v", action, d.step) | |
| 221 | + | } | |
| 222 | + | }) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) { | |
| 227 | + | manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |
| 228 | + | t.Cleanup(manager.Close) | |
| 229 | + | i := NewInteractive(InteractiveConfig{AuthManager: manager}) | |
| 230 | + | i.dialog.Open(t.TempDir()) | |
| 231 | + | i.dialog.method = "oauth" | |
| 232 | + | i.dialog.provider = "anthropic" | |
| 233 | + | i.dialog.step = loginStepOAuthMethod | |
| 234 | + | i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown}) | |
| 235 | + | act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 236 | + | if !act.StartManual || act.Provider != "anthropic" { | |
| 237 | + | t.Fatalf("expected manual Anthropic transaction, got %+v", act) | |
| 238 | + | } | |
| 239 | + | i.startManualOAuthFlow(act.Provider) | |
| 240 | + | i.handleAuthEvent(<-manager.Events()) | |
| 241 | + | if i.dialog.step != loginStepPasteCode { | |
| 242 | + | t.Fatalf("step = %v, want paste code", i.dialog.step) | |
| 243 | + | } | |
| 244 | + | u, err := url.Parse(i.dialog.url) | |
| 245 | + | if err != nil { | |
| 246 | + | t.Fatal(err) | |
| 247 | + | } | |
| 248 | + | if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() { | |
| 249 | + | t.Fatalf("redirect URI = %q, want copy-code redirect", got) | |
| 250 | + | } | |
| 251 | + | if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" { | |
| 252 | + | t.Fatalf("manual transaction not ready for code entry: %v", err) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func TestLoginDialogCloseCancelsOAuthTransaction(t *testing.T) { | |
| 257 | + | manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |
| 258 | + | t.Cleanup(manager.Close) | |
| 259 | + | i := NewInteractive(InteractiveConfig{AuthManager: manager}) | |
| 260 | + | i.dialog.Open(t.TempDir()) | |
| 261 | + | i.dialog.method = "oauth" | |
| 262 | + | i.dialog.provider = "anthropic" | |
| 263 | + | if _, err := manager.StartManualOAuth("anthropic"); err != nil { | |
| 264 | + | t.Fatal(err) | |
| 265 | + | } | |
| 266 | + | i.dialog.ShowPasteCode("https://example.com/oauth/authorize") | |
| 267 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |
| 268 | + | if i.dialog.Active() { | |
| 269 | + | t.Fatal("dialog still active after esc") | |
| 270 | + | } | |
| 271 | + | if err := manager.CompleteManualOAuth(context.Background(), "code"); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") { | |
| 272 | + | t.Fatalf("oauth transaction survived dialog close: %v", err) | |
| 273 | + | } | |
| 274 | + | } | |
A packages/agent/modes/skill_pins.go +423 -0
| @@ -0,0 +1,423 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "sync" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 8 | + | "github.com/bnema/zut/packages/tui" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // skillPinState holds the pinned-skill preload state of one interactive | |
| 12 | + | // conversation. Interactive owns one value and guards the mutable fields with | |
| 13 | + | // its own mutex, so every method except fetch and runToggle assumes the caller | |
| 14 | + | // holds that lock. Nothing here contacts a provider; the pending | |
| 15 | + | // bodies only join the first genuine user prompt (see consume). | |
| 16 | + | // | |
| 17 | + | // The three function fields are host callbacks (InteractiveConfig | |
| 18 | + | // LoadSkillPins, ToggleSkillPin, SkillSnapshot). Any may be nil: without | |
| 19 | + | // load there are no pins, without toggle the picker is read-only. They do | |
| 20 | + | // file and directory I/O, so they must never run under the interactive mutex. | |
| 21 | + | // Callers prefetch (fetch, runToggle) outside it and publish the result under | |
| 22 | + | // it (armFetched, applyRefresh, completeToggle). | |
| 23 | + | type skillPinState struct { | |
| 24 | + | load func(cwd string) (skills.Pins, error) | |
| 25 | + | toggle func(cwd, name string, global bool) error | |
| 26 | + | snapshot func() []*skills.Skill | |
| 27 | + | ||
| 28 | + | // ioMu serializes prefetch flows (fetch or toggle followed by publish) so | |
| 29 | + | // a reload never publishes preferences older than a completed save. It is | |
| 30 | + | // taken before agentMu and the interactive mutex; nothing waits for it | |
| 31 | + | // while holding either. | |
| 32 | + | ioMu sync.Mutex | |
| 33 | + | ||
| 34 | + | pending bool | |
| 35 | + | pins skills.Pins | |
| 36 | + | selected []*skills.Skill | |
| 37 | + | // gen changes whenever the conversation is armed or disarmed (fresh | |
| 38 | + | // conversation, /clear, /cd, resume, import, fork); rev also changes when | |
| 39 | + | // the preload is consumed or recalled. A prefetch records both so a result | |
| 40 | + | // overtaken by newer state is never published over it. | |
| 41 | + | gen uint64 | |
| 42 | + | rev uint64 | |
| 43 | + | // Remember the one expanded user prompt so queue recall and input history | |
| 44 | + | // expose the user's draft, never the injected instruction bodies. The | |
| 45 | + | // consumed selection is kept with it so recall restores exactly what was | |
| 46 | + | // consumed without touching the disk again. | |
| 47 | + | expanded string | |
| 48 | + | original string | |
| 49 | + | consumedSelected []*skills.Skill | |
| 50 | + | consumedPins skills.Pins | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // skillPinFetch is the result of prefetching preferences (and, for arming, | |
| 54 | + | // the discoverable skills) outside the interactive mutex. | |
| 55 | + | type skillPinFetch struct { | |
| 56 | + | pins skills.Pins | |
| 57 | + | err error | |
| 58 | + | available []*skills.Skill | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // skillPinMark records the state a prefetch started from. | |
| 62 | + | type skillPinMark struct { | |
| 63 | + | gen, rev uint64 | |
| 64 | + | cwd string | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // skillPinToggle is a validated picker request captured under the mutex and | |
| 68 | + | // executed outside it. list is the dialog's already-fetched skill list. | |
| 69 | + | type skillPinToggle struct { | |
| 70 | + | cwd, name string | |
| 71 | + | global bool | |
| 72 | + | list []*skills.Skill | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | const skillPinsSavedNotice = "skill pins updated (apply on the next fresh conversation or /clear)" | |
| 76 | + | ||
| 77 | + | func newSkillPinState(load func(string) (skills.Pins, error), toggle func(string, string, bool) error, snapshot func() []*skills.Skill) skillPinState { | |
| 78 | + | return skillPinState{load: load, toggle: toggle, snapshot: snapshot} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // mark captures the current generation, revision and cwd for a later publish. | |
| 82 | + | func (s *skillPinState) mark(cwd string) skillPinMark { | |
| 83 | + | return skillPinMark{gen: s.gen, rev: s.rev, cwd: cwd} | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // fetch loads the preferences for cwd and, when withSkills is set, the | |
| 87 | + | // discoverable skills. It touches no mutable state, so it runs without the | |
| 88 | + | // interactive mutex. Without a loader it does nothing. | |
| 89 | + | func (s *skillPinState) fetch(cwd string, withSkills bool) skillPinFetch { | |
| 90 | + | var f skillPinFetch | |
| 91 | + | if s.load == nil { | |
| 92 | + | return f | |
| 93 | + | } | |
| 94 | + | f.pins, f.err = s.load(cwd) | |
| 95 | + | if withSkills && f.err == nil && len(f.pins.Global)+len(f.pins.Project) > 0 && s.snapshot != nil { | |
| 96 | + | f.available = s.snapshot() | |
| 97 | + | } | |
| 98 | + | return f | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // disarm turns preloading off for a resumed, imported or forked conversation. | |
| 102 | + | // It never performs I/O. | |
| 103 | + | func (s *skillPinState) disarm() { | |
| 104 | + | s.pending = false | |
| 105 | + | s.selected = nil | |
| 106 | + | s.expanded, s.original = "", "" | |
| 107 | + | s.consumedSelected, s.consumedPins = nil, skills.Pins{} | |
| 108 | + | s.gen++ | |
| 109 | + | s.rev++ | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // armFetched prepares preloading for a fresh conversation (or /clear, /cd) | |
| 113 | + | // from a prefetch made for the current cwd. Pending and the resolved | |
| 114 | + | // selection are published together. It returns warnings. | |
| 115 | + | func (s *skillPinState) armFetched(f skillPinFetch, d *skillsDialog) []string { | |
| 116 | + | s.disarm() | |
| 117 | + | if s.load == nil { | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | s.pending = true | |
| 121 | + | s.pins = f.pins | |
| 122 | + | if d != nil { | |
| 123 | + | d.pins = f.pins | |
| 124 | + | d.canPin = s.toggle != nil | |
| 125 | + | } | |
| 126 | + | return s.resolveLocked(f) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // resolveLocked resolves the selection from f and returns warnings for | |
| 130 | + | // unreadable preferences and pinned names no longer discoverable. | |
| 131 | + | func (s *skillPinState) resolveLocked(f skillPinFetch) []string { | |
| 132 | + | var warnings []string | |
| 133 | + | if f.err != nil { | |
| 134 | + | warnings = append(warnings, f.err.Error()) | |
| 135 | + | } | |
| 136 | + | var missing []string | |
| 137 | + | s.selected, missing = f.pins.Resolve(f.available) | |
| 138 | + | return append(warnings, missing...) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // applyRefresh publishes a prefetch made from m without changing whether the | |
| 142 | + | // preload is pending; cwd is the current working directory. A prefetch for | |
| 143 | + | // another cwd or conversation (gen) is dropped whole. One overtaken by a | |
| 144 | + | // consume or recall (rev) only feeds the dialog's markers, so it cannot | |
| 145 | + | // replace an exactly restored selection. Otherwise the state's pins are | |
| 146 | + | // updated and, while pending, the selection is re-resolved from f.available. | |
| 147 | + | func (s *skillPinState) applyRefresh(m skillPinMark, cwd string, f skillPinFetch, d *skillsDialog) []string { | |
| 148 | + | if s.load == nil || m.cwd != cwd || m.gen != s.gen { | |
| 149 | + | return nil | |
| 150 | + | } | |
| 151 | + | if d != nil { | |
| 152 | + | d.pins = f.pins | |
| 153 | + | d.canPin = s.toggle != nil | |
| 154 | + | } | |
| 155 | + | var warnings []string | |
| 156 | + | if m.rev == s.rev { | |
| 157 | + | s.pins = f.pins | |
| 158 | + | if s.pending { | |
| 159 | + | return s.resolveLocked(f) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | if f.err != nil { | |
| 163 | + | warnings = append(warnings, f.err.Error()) | |
| 164 | + | } | |
| 165 | + | return warnings | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // toggleRequest validates a picker key: 'p' (project) or 'g' (global) on a | |
| 169 | + | // pinnable, listed skill. ok is false when the key is not a pin key or the | |
| 170 | + | // picker is not in a state that pins (closed, body view, empty, read-only | |
| 171 | + | // host); the caller then forwards the key to the dialog. | |
| 172 | + | func (s *skillPinState) toggleRequest(k tui.Key, cwd string, d *skillsDialog) (req skillPinToggle, ok bool) { | |
| 173 | + | if k.Kind != tui.KeyRune || (k.Rune != 'p' && k.Rune != 'g') { | |
| 174 | + | return req, false | |
| 175 | + | } | |
| 176 | + | sk := d.selectedSkill() | |
| 177 | + | if sk == nil || !d.canPin || s.toggle == nil { | |
| 178 | + | return req, false | |
| 179 | + | } | |
| 180 | + | return skillPinToggle{cwd: cwd, name: sk.Name, global: k.Rune == 'g', list: d.skills}, true | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // runToggle saves the toggle and reloads the preferences. It performs file | |
| 184 | + | // I/O and must run without the interactive mutex. | |
| 185 | + | func (s *skillPinState) runToggle(req skillPinToggle) (f skillPinFetch, saveErr error) { | |
| 186 | + | if saveErr = s.toggle(req.cwd, req.name, req.global); saveErr != nil { | |
| 187 | + | return f, saveErr | |
| 188 | + | } | |
| 189 | + | f = s.fetch(req.cwd, false) | |
| 190 | + | f.available = req.list | |
| 191 | + | return f, nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // completeToggle publishes a finished toggle. statusErr carries a save | |
| 195 | + | // failure and statusOK the success notice; pins are refreshed on success from | |
| 196 | + | // the dialog's own skill list. | |
| 197 | + | func (s *skillPinState) completeToggle(m skillPinMark, cwd string, f skillPinFetch, saveErr error, d *skillsDialog) (statusOK, statusErr string, warnings []string) { | |
| 198 | + | if saveErr != nil { | |
| 199 | + | return "", "save skill pin: " + saveErr.Error(), nil | |
| 200 | + | } | |
| 201 | + | return skillPinsSavedNotice, "", s.applyRefresh(m, cwd, f, d) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // consume folds the pending skill bodies into prompt exactly once. Later calls | |
| 205 | + | // return the prompt unchanged until the conversation is armed again. The | |
| 206 | + | // consumed selection stays in the state so recall can restore it exactly. | |
| 207 | + | func (s *skillPinState) consume(prompt string) string { | |
| 208 | + | if !s.pending { | |
| 209 | + | return prompt | |
| 210 | + | } | |
| 211 | + | s.pending = false | |
| 212 | + | s.rev++ | |
| 213 | + | out := skills.PreloadPrompt(s.selected, prompt) | |
| 214 | + | if out != prompt { | |
| 215 | + | s.expanded, s.original = out, prompt | |
| 216 | + | s.consumedSelected, s.consumedPins = s.selected, s.pins | |
| 217 | + | } | |
| 218 | + | s.selected = nil | |
| 219 | + | return out | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // rawPrompt removes only the exact expansion generated by this conversation. | |
| 223 | + | // Arbitrary user text resembling a pinned-skill preamble is never stripped. | |
| 224 | + | func (s *skillPinState) rawPrompt(prompt string) string { | |
| 225 | + | if s.expanded != "" && prompt == s.expanded { | |
| 226 | + | return s.original | |
| 227 | + | } | |
| 228 | + | return prompt | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // recall releases an undelivered preload so the next user submit can carry it. | |
| 232 | + | // It restores exactly the consumed selection without discovery or preference | |
| 233 | + | // I/O, so it is safe under the interactive mutex; queue removal serializes | |
| 234 | + | // delivery. | |
| 235 | + | func (s *skillPinState) recall(prompt string) { | |
| 236 | + | if s.expanded == "" || prompt != s.expanded || s.load == nil { | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | s.pending = true | |
| 240 | + | s.selected, s.pins = s.consumedSelected, s.consumedPins | |
| 241 | + | s.rev++ | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // notice renders the read-only "[Pinned skills]" section shown above the | |
| 245 | + | // input while pins are pending, never as editor text. | |
| 246 | + | func (s *skillPinState) notice(th tui.Theme, cols int) []string { | |
| 247 | + | if !s.pending || len(s.selected) == 0 || cols < 4 { | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | names := make([]string, 0, len(s.selected)) | |
| 251 | + | for _, sk := range s.selected { | |
| 252 | + | name := sk.Name | |
| 253 | + | project, global := s.pins.Scopes(s.selected, sk) | |
| 254 | + | switch { | |
| 255 | + | case project && global: | |
| 256 | + | name += " (project + global)" | |
| 257 | + | case project: | |
| 258 | + | name += " (project)" | |
| 259 | + | case global: | |
| 260 | + | name += " (global)" | |
| 261 | + | } | |
| 262 | + | names = append(names, name) | |
| 263 | + | } | |
| 264 | + | return tui.RenderPinnedSkills(th, names, cols) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // SetPinnedSkillsPending arms (true) or disarms (false) the pinned-skill | |
| 268 | + | // preload for the current conversation. Hosts disarm after loading a resumed, | |
| 269 | + | // imported or forked session; /clear and /cd re-arm internally. Arming | |
| 270 | + | // prefetches outside the mutex and is abandoned if the preload state changed | |
| 271 | + | // meanwhile (including consumption or recall). | |
| 272 | + | func (i *Interactive) SetPinnedSkillsPending(pending bool) { | |
| 273 | + | if !pending { | |
| 274 | + | i.mu.Lock() | |
| 275 | + | i.skillPins.disarm() | |
| 276 | + | i.mu.Unlock() | |
| 277 | + | i.invalidate() | |
| 278 | + | return | |
| 279 | + | } | |
| 280 | + | i.skillPins.ioMu.Lock() | |
| 281 | + | defer i.skillPins.ioMu.Unlock() | |
| 282 | + | i.mu.Lock() | |
| 283 | + | rev := i.skillPins.rev | |
| 284 | + | i.mu.Unlock() | |
| 285 | + | var f skillPinFetch | |
| 286 | + | cwd := i.skillPinCWD() | |
| 287 | + | for { | |
| 288 | + | f = i.skillPins.fetch(cwd, true) | |
| 289 | + | i.mu.Lock() | |
| 290 | + | if i.skillPins.rev != rev { | |
| 291 | + | i.mu.Unlock() | |
| 292 | + | return | |
| 293 | + | } | |
| 294 | + | if i.cfg.CWD == cwd { | |
| 295 | + | break | |
| 296 | + | } | |
| 297 | + | cwd = i.cfg.CWD | |
| 298 | + | i.mu.Unlock() | |
| 299 | + | } | |
| 300 | + | i.armSkillPinsLocked(f) | |
| 301 | + | i.mu.Unlock() | |
| 302 | + | i.invalidate() | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // skillPinCWD reads the working directory under the mutex. | |
| 306 | + | func (i *Interactive) skillPinCWD() string { | |
| 307 | + | i.mu.Lock() | |
| 308 | + | defer i.mu.Unlock() | |
| 309 | + | return i.cfg.CWD | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // lockWithSkillPinFetch prefetches the preferences and discoverable skills for | |
| 313 | + | // the current cwd without the mutex, then returns holding i.mu with the fetch | |
| 314 | + | // still matching i.cfg.CWD (it refetches if the cwd moved meanwhile). The | |
| 315 | + | // caller publishes with armSkillPinsLocked, unlocks i.mu, then calls done. | |
| 316 | + | func (i *Interactive) lockWithSkillPinFetch() (f skillPinFetch, done func()) { | |
| 317 | + | i.skillPins.ioMu.Lock() | |
| 318 | + | cwd := i.skillPinCWD() | |
| 319 | + | for { | |
| 320 | + | f = i.skillPins.fetch(cwd, true) | |
| 321 | + | i.mu.Lock() | |
| 322 | + | if i.cfg.CWD == cwd { | |
| 323 | + | return f, i.skillPins.ioMu.Unlock | |
| 324 | + | } | |
| 325 | + | cwd = i.cfg.CWD | |
| 326 | + | i.mu.Unlock() | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // armSkillPinsLocked arms the preload for a fresh conversation from a prefetch | |
| 331 | + | // for the current cwd and reports warnings once. The caller must hold i.mu. | |
| 332 | + | func (i *Interactive) armSkillPinsLocked(f skillPinFetch) { | |
| 333 | + | i.addSkillPinWarningsLocked(i.skillPins.armFetched(f, i.skillsDialog)) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // addSkillPinWarningsLocked shows pin warnings in the host-only reload error | |
| 337 | + | // list, skipping ones already shown. The caller must hold i.mu. | |
| 338 | + | func (i *Interactive) addSkillPinWarningsLocked(warnings []string) { | |
| 339 | + | for _, w := range warnings { | |
| 340 | + | if w = "skill pins: " + w; !slices.Contains(i.reloadErrors, w) { | |
| 341 | + | i.reloadErrors = append(i.reloadErrors, w) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // refreshPendingSkillPins re-resolves a pending selection (entry.pre may have | |
| 347 | + | // added or removed skills). Nothing is read when nothing is pending. The | |
| 348 | + | // callbacks run without the mutex; a result overtaken by a consume, recall, | |
| 349 | + | // arm or cwd change is dropped. | |
| 350 | + | func (i *Interactive) refreshPendingSkillPins() { | |
| 351 | + | i.skillPins.ioMu.Lock() | |
| 352 | + | defer i.skillPins.ioMu.Unlock() | |
| 353 | + | i.mu.Lock() | |
| 354 | + | if !i.skillPins.pending || i.skillPins.load == nil { | |
| 355 | + | i.mu.Unlock() | |
| 356 | + | return | |
| 357 | + | } | |
| 358 | + | mark := i.skillPins.mark(i.cfg.CWD) | |
| 359 | + | i.mu.Unlock() | |
| 360 | + | f := i.skillPins.fetch(mark.cwd, true) | |
| 361 | + | i.mu.Lock() | |
| 362 | + | i.addSkillPinWarningsLocked(i.skillPins.applyRefresh(mark, i.cfg.CWD, f, i.skillsDialog)) | |
| 363 | + | i.mu.Unlock() | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // openSkillsDialogWithPins opens the picker on a single skill snapshot. The | |
| 367 | + | // preferences load outside the mutex, and a pending selection re-resolves from | |
| 368 | + | // that same list rather than walking discovery again. If the cwd moved while | |
| 369 | + | // loading, the whole prefetch is redone. | |
| 370 | + | func (i *Interactive) openSkillsDialogWithPins() { | |
| 371 | + | i.skillPins.ioMu.Lock() | |
| 372 | + | defer i.skillPins.ioMu.Unlock() | |
| 373 | + | for { | |
| 374 | + | i.mu.Lock() | |
| 375 | + | mark := i.skillPins.mark(i.cfg.CWD) | |
| 376 | + | i.mu.Unlock() | |
| 377 | + | var list []*skills.Skill | |
| 378 | + | if i.cfg.SkillSnapshot != nil { | |
| 379 | + | list = i.cfg.SkillSnapshot() | |
| 380 | + | } | |
| 381 | + | f := i.skillPins.fetch(mark.cwd, false) | |
| 382 | + | f.available = list | |
| 383 | + | i.mu.Lock() | |
| 384 | + | if i.cfg.CWD == mark.cwd { | |
| 385 | + | i.skillsDialog.Open(list) | |
| 386 | + | i.addSkillPinWarningsLocked(i.skillPins.applyRefresh(mark, i.cfg.CWD, f, i.skillsDialog)) | |
| 387 | + | i.mu.Unlock() | |
| 388 | + | return | |
| 389 | + | } | |
| 390 | + | i.mu.Unlock() | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | // consumePinnedSkills folds pending pins into the first genuine user prompt. | |
| 395 | + | func (i *Interactive) consumePinnedSkills(prompt string) string { | |
| 396 | + | i.mu.Lock() | |
| 397 | + | defer i.mu.Unlock() | |
| 398 | + | return i.skillPins.consume(prompt) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | // handleSkillPinKey applies the picker's p/g keys. It reports whether the key | |
| 402 | + | // was consumed. The preference save and reload run without the mutex; the | |
| 403 | + | // result is applied under it from the dialog's own skill list. | |
| 404 | + | func (i *Interactive) handleSkillPinKey(k tui.Key) bool { | |
| 405 | + | i.mu.Lock() | |
| 406 | + | req, ok := i.skillPins.toggleRequest(k, i.cfg.CWD, i.skillsDialog) | |
| 407 | + | i.mu.Unlock() | |
| 408 | + | if !ok { | |
| 409 | + | return false | |
| 410 | + | } | |
| 411 | + | i.skillPins.ioMu.Lock() | |
| 412 | + | defer i.skillPins.ioMu.Unlock() | |
| 413 | + | i.mu.Lock() | |
| 414 | + | mark := i.skillPins.mark(req.cwd) | |
| 415 | + | i.mu.Unlock() | |
| 416 | + | f, saveErr := i.skillPins.runToggle(req) | |
| 417 | + | i.mu.Lock() | |
| 418 | + | okText, errText, warnings := i.skillPins.completeToggle(mark, i.cfg.CWD, f, saveErr, i.skillsDialog) | |
| 419 | + | i.statusOK, i.statusErr = okText, errText | |
| 420 | + | i.addSkillPinWarningsLocked(warnings) | |
| 421 | + | i.mu.Unlock() | |
| 422 | + | return true | |
| 423 | + | } | |
A packages/agent/modes/skill_pins_lock_test.go +415 -0
| @@ -0,0 +1,415 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 12 | + | "github.com/bnema/zut/packages/core" | |
| 13 | + | "github.com/bnema/zut/packages/tui" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Synchronous helpers composing the prefetch and publish steps for state-level | |
| 17 | + | // tests that have no Interactive mutex to release. | |
| 18 | + | ||
| 19 | + | func (s *skillPinState) arm(pending bool, cwd string, d *skillsDialog) []string { | |
| 20 | + | if !pending { | |
| 21 | + | s.disarm() | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | return s.armFetched(s.fetch(cwd, true), d) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (s *skillPinState) refresh(cwd string, d *skillsDialog) []string { | |
| 28 | + | m := s.mark(cwd) | |
| 29 | + | return s.applyRefresh(m, cwd, s.fetch(cwd, true), d) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (s *skillPinState) handleKey(k tui.Key, cwd string, d *skillsDialog) (handled bool, statusOK, statusErr string, warnings []string) { | |
| 33 | + | req, ok := s.toggleRequest(k, cwd, d) | |
| 34 | + | if !ok { | |
| 35 | + | return false, "", "", nil | |
| 36 | + | } | |
| 37 | + | m := s.mark(cwd) | |
| 38 | + | f, err := s.runToggle(req) | |
| 39 | + | statusOK, statusErr, warnings = s.completeToggle(m, cwd, f, err, d) | |
| 40 | + | return true, statusOK, statusErr, warnings | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // mutexProbe fails the test when a callback runs while i.mu is held. | |
| 44 | + | type mutexProbe struct { | |
| 45 | + | t *testing.T | |
| 46 | + | i *Interactive | |
| 47 | + | mu sync.Mutex | |
| 48 | + | calls map[string]int | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func newMutexProbe(t *testing.T, i *Interactive) *mutexProbe { | |
| 52 | + | return &mutexProbe{t: t, i: i, calls: map[string]int{}} | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (p *mutexProbe) hit(name string) { | |
| 56 | + | p.t.Helper() | |
| 57 | + | if !p.i.mu.TryLock() { | |
| 58 | + | p.t.Errorf("%s ran while i.mu was held", name) | |
| 59 | + | } else { | |
| 60 | + | p.i.mu.Unlock() | |
| 61 | + | } | |
| 62 | + | p.mu.Lock() | |
| 63 | + | p.calls[name]++ | |
| 64 | + | p.mu.Unlock() | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (p *mutexProbe) count(name string) int { | |
| 68 | + | p.mu.Lock() | |
| 69 | + | defer p.mu.Unlock() | |
| 70 | + | return p.calls[name] | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (p *mutexProbe) reset() { | |
| 74 | + | p.mu.Lock() | |
| 75 | + | p.calls = map[string]int{} | |
| 76 | + | p.mu.Unlock() | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func newProbedInteractive(t *testing.T, preload bool) (*Interactive, *mutexProbe) { | |
| 80 | + | t.Helper() | |
| 81 | + | var i *Interactive | |
| 82 | + | var probe *mutexProbe | |
| 83 | + | pins := skills.Pins{Global: []string{"review"}} | |
| 84 | + | i = NewInteractive(InteractiveConfig{ | |
| 85 | + | Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), | |
| 86 | + | LoadSkillPins: func(string) (skills.Pins, error) { | |
| 87 | + | if probe != nil { | |
| 88 | + | probe.hit("load") | |
| 89 | + | } | |
| 90 | + | return pins, nil | |
| 91 | + | }, | |
| 92 | + | ToggleSkillPin: func(_, name string, _ bool) error { | |
| 93 | + | probe.hit("toggle") | |
| 94 | + | pins = skills.Pins{Global: []string{name}} | |
| 95 | + | return nil | |
| 96 | + | }, | |
| 97 | + | SkillSnapshot: func() []*skills.Skill { | |
| 98 | + | if probe != nil { | |
| 99 | + | probe.hit("snapshot") | |
| 100 | + | } | |
| 101 | + | return reviewSnapshot() | |
| 102 | + | }, | |
| 103 | + | PreloadPinnedSkills: preload, | |
| 104 | + | }) | |
| 105 | + | probe = newMutexProbe(t, i) | |
| 106 | + | return i, probe | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestSkillPinCallbacksRunOutsideInteractiveMutex(t *testing.T) { | |
| 110 | + | t.Run("startup arm", func(t *testing.T) { | |
| 111 | + | i, p := newProbedInteractive(t, false) | |
| 112 | + | i.SetPinnedSkillsPending(true) | |
| 113 | + | if p.count("load") != 1 || p.count("snapshot") != 1 { | |
| 114 | + | t.Fatalf("arm callbacks: %v", p.calls) | |
| 115 | + | } | |
| 116 | + | if !strings.Contains(i.consumePinnedSkills("x"), "Inspect carefully.") { | |
| 117 | + | t.Fatal("arm lost selection") | |
| 118 | + | } | |
| 119 | + | }) | |
| 120 | + | t.Run("clear", func(t *testing.T) { | |
| 121 | + | i, p := newProbedInteractive(t, true) | |
| 122 | + | i.consumePinnedSkills("first") | |
| 123 | + | p.reset() | |
| 124 | + | i.runSlash(context.Background(), "/clear") | |
| 125 | + | if p.count("load") != 1 || p.count("snapshot") != 1 { | |
| 126 | + | t.Fatalf("clear callbacks: %v", p.calls) | |
| 127 | + | } | |
| 128 | + | if !strings.Contains(i.consumePinnedSkills("x"), "Inspect carefully.") { | |
| 129 | + | t.Fatal("/clear did not re-arm") | |
| 130 | + | } | |
| 131 | + | }) | |
| 132 | + | t.Run("cd", func(t *testing.T) { | |
| 133 | + | i, p := newProbedInteractive(t, true) | |
| 134 | + | i.consumePinnedSkills("first") | |
| 135 | + | p.reset() | |
| 136 | + | i.ApplyChangedCWD(core.NewAgent(nil, "test-model", "", nil), "p", "m", t.TempDir()) | |
| 137 | + | if p.count("load") != 1 || p.count("snapshot") != 1 { | |
| 138 | + | t.Fatalf("cd callbacks: %v", p.calls) | |
| 139 | + | } | |
| 140 | + | if !strings.Contains(i.consumePinnedSkills("x"), "Inspect carefully.") { | |
| 141 | + | t.Fatal("/cd did not re-arm") | |
| 142 | + | } | |
| 143 | + | }) | |
| 144 | + | t.Run("skills dialog and toggle", func(t *testing.T) { | |
| 145 | + | i, p := newProbedInteractive(t, true) | |
| 146 | + | p.reset() | |
| 147 | + | i.openSkillsDialog() | |
| 148 | + | if p.count("snapshot") != 1 || p.count("load") != 1 { | |
| 149 | + | t.Fatalf("/skills must walk discovery once: %v", p.calls) | |
| 150 | + | } | |
| 151 | + | p.reset() | |
| 152 | + | if !i.handleSkillPinKey(pinKey('p')) { | |
| 153 | + | t.Fatal("pin key not handled") | |
| 154 | + | } | |
| 155 | + | if p.count("toggle") != 1 || p.count("load") != 1 || p.count("snapshot") != 0 { | |
| 156 | + | t.Fatalf("toggle callbacks: %v", p.calls) | |
| 157 | + | } | |
| 158 | + | i.mu.Lock() | |
| 159 | + | ok := i.statusOK | |
| 160 | + | i.mu.Unlock() | |
| 161 | + | if ok == "" { | |
| 162 | + | t.Fatal("toggle status missing") | |
| 163 | + | } | |
| 164 | + | }) | |
| 165 | + | t.Run("startup pre", func(t *testing.T) { | |
| 166 | + | i, p := newProbedInteractive(t, true) | |
| 167 | + | p.reset() | |
| 168 | + | i.applyStartupPreResult(startupPreResult{}) | |
| 169 | + | if p.count("load") != 1 || p.count("snapshot") != 1 { | |
| 170 | + | t.Fatalf("startup callbacks: %v", p.calls) | |
| 171 | + | } | |
| 172 | + | i.consumePinnedSkills("first") | |
| 173 | + | p.reset() | |
| 174 | + | i.applyStartupPreResult(startupPreResult{}) | |
| 175 | + | if len(p.calls) != 0 { | |
| 176 | + | t.Fatalf("consumed pins were refreshed: %v", p.calls) | |
| 177 | + | } | |
| 178 | + | }) | |
| 179 | + | t.Run("disarm", func(t *testing.T) { | |
| 180 | + | i, p := newProbedInteractive(t, true) | |
| 181 | + | p.reset() | |
| 182 | + | i.SetPinnedSkillsPending(false) | |
| 183 | + | i.ApplySessionAgent(core.NewAgent(nil, "test-model", "", nil), "p", "m") | |
| 184 | + | if len(p.calls) != 0 { | |
| 185 | + | t.Fatalf("disarm did IO: %v", p.calls) | |
| 186 | + | } | |
| 187 | + | }) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestSkillPinRecallAndDiscardDoNoIOAndRestoreExactSelection(t *testing.T) { | |
| 191 | + | i, p := newProbedInteractive(t, true) | |
| 192 | + | expanded := i.consumePinnedSkills("draft") | |
| 193 | + | p.reset() | |
| 194 | + | // Preferences and discovery change after consumption; recall must still | |
| 195 | + | // restore exactly what was consumed, without reading either again. | |
| 196 | + | i.mu.Lock() | |
| 197 | + | i.skillPins.load = func(string) (skills.Pins, error) { t.Error("recall loaded pins"); return skills.Pins{}, nil } | |
| 198 | + | i.skillPins.snapshot = func() []*skills.Skill { t.Error("recall walked discovery"); return nil } | |
| 199 | + | i.mu.Unlock() | |
| 200 | + | ||
| 201 | + | if !i.restoreQueuedMessageToEditor(core.QueuedMessage{Text: expanded}) { | |
| 202 | + | t.Fatal("restore failed") | |
| 203 | + | } | |
| 204 | + | if i.ed.Value() != "draft" { | |
| 205 | + | t.Fatalf("editor = %q", i.ed.Value()) | |
| 206 | + | } | |
| 207 | + | i.mu.Lock() | |
| 208 | + | notice := stripANSIBytes(strings.Join(i.skillPins.notice(tui.Theme{}, 80), "")) | |
| 209 | + | i.mu.Unlock() | |
| 210 | + | if !strings.Contains(notice, "review (global)") { | |
| 211 | + | t.Fatalf("scope marker lost: %q", notice) | |
| 212 | + | } | |
| 213 | + | if next := i.consumePinnedSkills("draft"); next != expanded { | |
| 214 | + | t.Fatalf("recalled selection differs:\n%q\n%q", next, expanded) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | i.agent.QueuePrompt(core.QueuedMessage{Text: expanded}) | |
| 218 | + | i.mu.Lock() | |
| 219 | + | i.discardQueuedMessagesLocked(false) | |
| 220 | + | i.mu.Unlock() | |
| 221 | + | if next := i.consumePinnedSkills("again"); strings.Count(next, "Inspect carefully.") != 1 { | |
| 222 | + | t.Fatalf("discard lost pins: %q", next) | |
| 223 | + | } | |
| 224 | + | if len(p.calls) != 0 { | |
| 225 | + | t.Fatalf("recall did IO: %v", p.calls) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestSkillPinNoPendingWithEmptySelectionPublished(t *testing.T) { | |
| 230 | + | // A blocked prefetch must not expose pending with a stale/empty selection. | |
| 231 | + | release := make(chan struct{}) | |
| 232 | + | entered := make(chan struct{}, 1) | |
| 233 | + | i := NewInteractive(InteractiveConfig{ | |
| 234 | + | Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), | |
| 235 | + | LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, | |
| 236 | + | SkillSnapshot: func() []*skills.Skill { | |
| 237 | + | select { | |
| 238 | + | case entered <- struct{}{}: | |
| 239 | + | default: | |
| 240 | + | } | |
| 241 | + | <-release | |
| 242 | + | return reviewSnapshot() | |
| 243 | + | }, | |
| 244 | + | }) | |
| 245 | + | done := make(chan struct{}) | |
| 246 | + | go func() { i.SetPinnedSkillsPending(true); close(done) }() | |
| 247 | + | <-entered | |
| 248 | + | i.mu.Lock() | |
| 249 | + | pending, selected := i.skillPins.pending, len(i.skillPins.selected) | |
| 250 | + | i.mu.Unlock() | |
| 251 | + | if pending || selected != 0 { | |
| 252 | + | t.Fatalf("published early: pending=%v selected=%d", pending, selected) | |
| 253 | + | } | |
| 254 | + | close(release) | |
| 255 | + | <-done | |
| 256 | + | i.mu.Lock() | |
| 257 | + | pending, selected = i.skillPins.pending, len(i.skillPins.selected) | |
| 258 | + | i.mu.Unlock() | |
| 259 | + | if !pending || selected != 1 { | |
| 260 | + | t.Fatalf("pending=%v selected=%d", pending, selected) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // blockingSnapshot lets a test hold one discovery walk open. | |
| 265 | + | type blockingSnapshot struct { | |
| 266 | + | entered chan struct{} | |
| 267 | + | release chan struct{} | |
| 268 | + | block bool | |
| 269 | + | empty bool // discovery finds nothing once released | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func (b *blockingSnapshot) snapshot() []*skills.Skill { | |
| 273 | + | if b.block { | |
| 274 | + | b.block = false | |
| 275 | + | b.entered <- struct{}{} | |
| 276 | + | <-b.release | |
| 277 | + | if b.empty { | |
| 278 | + | return nil | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | return reviewSnapshot() | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestStaleSkillPinArmDroppedAfterDisarmOrSessionReplacement(t *testing.T) { | |
| 285 | + | for name, overtake := range map[string]func(*Interactive){ | |
| 286 | + | "disarm": func(i *Interactive) { i.SetPinnedSkillsPending(false) }, | |
| 287 | + | "session replacement": func(i *Interactive) { i.ApplySessionAgent(core.NewAgent(nil, "test-model", "", nil), "p", "m") }, | |
| 288 | + | } { | |
| 289 | + | t.Run(name, func(t *testing.T) { | |
| 290 | + | b := &blockingSnapshot{entered: make(chan struct{}, 1), release: make(chan struct{}), block: true} | |
| 291 | + | i := NewInteractive(InteractiveConfig{ | |
| 292 | + | Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), | |
| 293 | + | LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, | |
| 294 | + | SkillSnapshot: b.snapshot, | |
| 295 | + | }) | |
| 296 | + | done := make(chan struct{}) | |
| 297 | + | go func() { i.SetPinnedSkillsPending(true); close(done) }() | |
| 298 | + | <-b.entered | |
| 299 | + | // The overtaking transition must not wait for the blocked callback. | |
| 300 | + | finished := make(chan struct{}) | |
| 301 | + | go func() { overtake(i); close(finished) }() | |
| 302 | + | select { | |
| 303 | + | case <-finished: | |
| 304 | + | case <-time.After(2 * time.Second): | |
| 305 | + | t.Fatal("transition blocked behind in-flight prefetch") | |
| 306 | + | } | |
| 307 | + | close(b.release) | |
| 308 | + | <-done | |
| 309 | + | if got := i.consumePinnedSkills("hello"); got != "hello" { | |
| 310 | + | t.Fatalf("stale arm published pins: %q", got) | |
| 311 | + | } | |
| 312 | + | }) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func TestStaleSkillPinRefreshDroppedAfterConsumeAndRecall(t *testing.T) { | |
| 317 | + | b := &blockingSnapshot{entered: make(chan struct{}, 1), release: make(chan struct{})} | |
| 318 | + | i := NewInteractive(InteractiveConfig{ | |
| 319 | + | Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), | |
| 320 | + | LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, | |
| 321 | + | SkillSnapshot: b.snapshot, PreloadPinnedSkills: true, | |
| 322 | + | }) | |
| 323 | + | // Startup refresh blocked in discovery while the user submits, then the | |
| 324 | + | // queued prompt is recalled. The exactly restored selection must survive | |
| 325 | + | // the (now stale) refresh, which would otherwise resolve to nothing. | |
| 326 | + | b.block = true | |
| 327 | + | done := make(chan struct{}) | |
| 328 | + | go func() { i.refreshPendingSkillPins(); close(done) }() | |
| 329 | + | <-b.entered | |
| 330 | + | expanded := i.consumePinnedSkills("draft") | |
| 331 | + | i.mu.Lock() | |
| 332 | + | i.skillPins.recall(expanded) | |
| 333 | + | i.mu.Unlock() | |
| 334 | + | // The blocked discovery finds nothing: a published stale result would | |
| 335 | + | // empty the restored selection. | |
| 336 | + | b.empty = true | |
| 337 | + | close(b.release) | |
| 338 | + | <-done | |
| 339 | + | if got := i.consumePinnedSkills("draft"); got != expanded { | |
| 340 | + | t.Fatalf("stale refresh replaced restored selection: %q", got) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func TestStaleSkillPinPrefetchDroppedAfterCWDOrGenerationChange(t *testing.T) { | |
| 345 | + | load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil } | |
| 346 | + | s := newSkillPinState(load, nil, reviewSnapshot) | |
| 347 | + | s.armFetched(s.fetch("/a", true), nil) | |
| 348 | + | before := s.selected | |
| 349 | + | ||
| 350 | + | mark := s.mark("/a") | |
| 351 | + | stale := skillPinFetch{pins: skills.Pins{Global: []string{"gone"}}} | |
| 352 | + | if w := s.applyRefresh(mark, "/b", stale, nil); w != nil || s.pins.Global[0] != "review" || len(s.selected) != len(before) { | |
| 353 | + | t.Fatalf("prefetch for another cwd published: pins=%+v warnings=%v", s.pins, w) | |
| 354 | + | } | |
| 355 | + | s.disarm() | |
| 356 | + | s.armFetched(s.fetch("/a", true), nil) | |
| 357 | + | s.applyRefresh(mark, "/a", stale, nil) | |
| 358 | + | if s.pins.Global[0] != "review" || len(s.selected) != 1 { | |
| 359 | + | t.Fatalf("prefetch from an older conversation published: %+v", s.pins) | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestEmptySkillPinsSkipDiscovery(t *testing.T) { | |
| 364 | + | loads := 0 | |
| 365 | + | i := NewInteractive(InteractiveConfig{ | |
| 366 | + | Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), PreloadPinnedSkills: true, | |
| 367 | + | LoadSkillPins: func(string) (skills.Pins, error) { loads++; return skills.Pins{}, nil }, | |
| 368 | + | SkillSnapshot: func() []*skills.Skill { t.Fatal("empty pins triggered discovery"); return nil }, | |
| 369 | + | }) | |
| 370 | + | i.refreshPendingSkillPins() | |
| 371 | + | i.runSlash(context.Background(), "/clear") | |
| 372 | + | i.applyChangedCWD(core.NewAgent(nil, "test-model", "", nil), "p", "m", t.TempDir(), nil) | |
| 373 | + | if loads < 4 { | |
| 374 | + | t.Fatalf("pin preferences not refreshed at lifecycle boundaries: %d loads", loads) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func TestStaleSkillPinArmDroppedAfterConsumption(t *testing.T) { | |
| 379 | + | b := &blockingSnapshot{entered: make(chan struct{}, 1), release: make(chan struct{})} | |
| 380 | + | i := NewInteractive(InteractiveConfig{ | |
| 381 | + | Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), PreloadPinnedSkills: true, | |
| 382 | + | LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, | |
| 383 | + | SkillSnapshot: b.snapshot, | |
| 384 | + | }) | |
| 385 | + | b.block = true | |
| 386 | + | done := make(chan struct{}) | |
| 387 | + | go func() { i.SetPinnedSkillsPending(true); close(done) }() | |
| 388 | + | <-b.entered | |
| 389 | + | if got := i.consumePinnedSkills("first"); !strings.Contains(got, "Inspect carefully.") { | |
| 390 | + | t.Fatalf("initial selection not consumed: %q", got) | |
| 391 | + | } | |
| 392 | + | close(b.release) | |
| 393 | + | <-done | |
| 394 | + | if got := i.consumePinnedSkills("second"); got != "second" { | |
| 395 | + | t.Fatalf("stale rearm duplicated preload: %q", got) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func TestSkillPinToggleFailureKeepsStateAndAppliesNoStatusOK(t *testing.T) { | |
| 400 | + | i := NewInteractive(InteractiveConfig{ | |
| 401 | + | Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), | |
| 402 | + | LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{}, nil }, | |
| 403 | + | ToggleSkillPin: func(string, string, bool) error { return errors.New("read-only") }, | |
| 404 | + | SkillSnapshot: reviewSnapshot, | |
| 405 | + | }) | |
| 406 | + | i.openSkillsDialog() | |
| 407 | + | if !i.handleSkillPinKey(pinKey('g')) { | |
| 408 | + | t.Fatal("not handled") | |
| 409 | + | } | |
| 410 | + | i.mu.Lock() | |
| 411 | + | defer i.mu.Unlock() | |
| 412 | + | if !strings.Contains(i.statusErr, "read-only") || i.statusOK != "" { | |
| 413 | + | t.Fatalf("status ok=%q err=%q", i.statusOK, i.statusErr) | |
| 414 | + | } | |
| 415 | + | } | |
A packages/agent/modes/skill_pins_recall_test.go +99 -0
| @@ -0,0 +1,99 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | + | "github.com/bnema/zut/packages/tui" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func newPinnedRecallInteractive(t *testing.T) *Interactive { | |
| 16 | + | t.Helper() | |
| 17 | + | return NewInteractive(InteractiveConfig{ | |
| 18 | + | Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), | |
| 19 | + | LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, | |
| 20 | + | SkillSnapshot: reviewSnapshot, PreloadPinnedSkills: true, | |
| 21 | + | }) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestPinnedQueuedDraftRecallKeepsEditorRawAndRearms(t *testing.T) { | |
| 25 | + | for name, key := range map[string]tui.Key{"alt-up": {Kind: tui.KeyUp, Alt: true}, "escape": {Kind: tui.KeyEsc}} { | |
| 26 | + | t.Run(name, func(t *testing.T) { | |
| 27 | + | i := newPinnedRecallInteractive(t) | |
| 28 | + | expanded := i.consumePinnedSkills("user draft") | |
| 29 | + | i.agent.QueuePrompt(core.QueuedMessage{Text: expanded}) | |
| 30 | + | i.mu.Lock() | |
| 31 | + | i.busy = true | |
| 32 | + | i.cancelTurn = func() {} | |
| 33 | + | i.mu.Unlock() | |
| 34 | + | i.handleKey(context.Background(), key) | |
| 35 | + | if text := i.ed.Value(); text != "user draft" { | |
| 36 | + | t.Fatalf("recalled editor text=%q, want raw draft", text) | |
| 37 | + | } | |
| 38 | + | if next := i.consumePinnedSkills(i.ed.Value()); strings.Count(next, "Inspect carefully.") != 1 { | |
| 39 | + | t.Fatalf("recall lost or doubled pins: %q", next) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestDiscardPinnedQueuedDraftRearmsNextUser(t *testing.T) { | |
| 46 | + | for _, preserveHost := range []bool{false, true} { | |
| 47 | + | i := newPinnedRecallInteractive(t) | |
| 48 | + | expanded := i.consumePinnedSkills("discarded draft") | |
| 49 | + | i.agent.QueuePrompt(core.QueuedMessage{Text: expanded}) | |
| 50 | + | i.agent.QueuePrompt(core.QueuedMessage{Text: "host report", HostEvent: true}) | |
| 51 | + | i.mu.Lock() | |
| 52 | + | i.discardQueuedMessagesLocked(preserveHost) | |
| 53 | + | i.mu.Unlock() | |
| 54 | + | if next := i.consumePinnedSkills("new draft"); strings.Count(next, "Inspect carefully.") != 1 || !strings.HasSuffix(next, "new draft") { | |
| 55 | + | t.Fatalf("discard lost pins: %q", next) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestCtrlCClearsPinnedQueueWithoutLosingPreload(t *testing.T) { | |
| 61 | + | i := newPinnedRecallInteractive(t) | |
| 62 | + | expanded := i.consumePinnedSkills("discarded draft") | |
| 63 | + | i.agent.QueuePrompt(core.QueuedMessage{Text: expanded}) | |
| 64 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlC}) | |
| 65 | + | if next := i.consumePinnedSkills("next draft"); strings.Count(next, "Inspect carefully.") != 1 { | |
| 66 | + | t.Fatalf("clearing queue lost pins: %q", next) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestPinnedRecallAndHistorySynchronizeWithHostArming(t *testing.T) { | |
| 71 | + | i := newPinnedRecallInteractive(t) | |
| 72 | + | expanded := i.consumePinnedSkills("user draft") | |
| 73 | + | i.agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: expanded}}}}) | |
| 74 | + | var wg sync.WaitGroup | |
| 75 | + | wg.Add(1) | |
| 76 | + | go func() { | |
| 77 | + | defer wg.Done() | |
| 78 | + | for range 100 { | |
| 79 | + | i.SetPinnedSkillsPending(true) | |
| 80 | + | } | |
| 81 | + | }() | |
| 82 | + | for range 100 { | |
| 83 | + | i.restoreQueuedMessageToEditor(core.QueuedMessage{Text: expanded}) | |
| 84 | + | i.inputHistory() | |
| 85 | + | } | |
| 86 | + | wg.Wait() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestPinnedInputHistoryShowsRawUserText(t *testing.T) { | |
| 90 | + | i := newPinnedRecallInteractive(t) | |
| 91 | + | expanded := i.consumePinnedSkills("user draft") | |
| 92 | + | i.agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: expanded}}}}) | |
| 93 | + | if history := i.inputHistory(); len(history) != 1 || history[0] != "user draft" { | |
| 94 | + | t.Fatalf("input history contains injected bodies: %#v", history) | |
| 95 | + | } | |
| 96 | + | if got := i.skillPins.rawPrompt("Pinned skills for this conversation.\nuser-written text"); !strings.Contains(got, "user-written text") { | |
| 97 | + | t.Fatal("stripped unrelated user-written preamble") | |
| 98 | + | } | |
| 99 | + | } | |
A packages/agent/modes/skill_pins_scope_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 8 | + | "github.com/bnema/zut/packages/tui" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSkillPinStateReadOnlyWithoutLoader(t *testing.T) { | |
| 12 | + | called := false | |
| 13 | + | state := newSkillPinState(nil, func(string, string, bool) error { called = true; return nil }, reviewSnapshot) | |
| 14 | + | dialog := newSkillsDialog() | |
| 15 | + | dialog.Open(reviewSnapshot()) | |
| 16 | + | state.refresh("/workspace", dialog) | |
| 17 | + | if handled, _, _, _ := state.handleKey(pinKey('p'), "/workspace", dialog); handled || called { | |
| 18 | + | t.Fatal("read-only picker wrote pin preferences") | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestSkillPinAliasesShowScopesAndPreloadOnce(t *testing.T) { | |
| 23 | + | skill := &skills.Skill{Name: "review", Aliases: []string{"group/review"}, Body: "Inspect carefully."} | |
| 24 | + | pins := skills.Pins{Project: []string{"group/review"}, Global: []string{"review"}} | |
| 25 | + | state := newSkillPinState(func(string) (skills.Pins, error) { return pins, nil }, func(string, string, bool) error { return nil }, func() []*skills.Skill { return []*skills.Skill{skill} }) | |
| 26 | + | dialog := newSkillsDialog() | |
| 27 | + | dialog.Open([]*skills.Skill{skill}) | |
| 28 | + | state.arm(true, "/workspace", dialog) | |
| 29 | + | if marker := dialog.pinMarker(skill); marker != "[pg] " { | |
| 30 | + | t.Fatalf("marker=%q, want [pg]", marker) | |
| 31 | + | } | |
| 32 | + | notice := stripANSIBytes(strings.Join(state.notice(tui.Theme{}, 80), "")) | |
| 33 | + | if !strings.Contains(notice, "review (project + global)") { | |
| 34 | + | t.Fatalf("alias scope missing from notice: %q", notice) | |
| 35 | + | } | |
| 36 | + | if prompt := state.consume("question"); strings.Count(prompt, "Inspect carefully.") != 1 { | |
| 37 | + | t.Fatalf("alias pin included body twice: %q", prompt) | |
| 38 | + | } | |
| 39 | + | } | |
A packages/agent/modes/skill_pins_test.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "slices" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 10 | + | "github.com/bnema/zut/packages/tui" | |
| 11 | + | "github.com/mattn/go-runewidth" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func reviewSnapshot() []*skills.Skill { | |
| 15 | + | return []*skills.Skill{{Name: "review", Body: "Inspect carefully."}} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func pinKey(r rune) tui.Key { return tui.Key{Kind: tui.KeyRune, Rune: r} } | |
| 19 | + | ||
| 20 | + | func TestSkillPinStateConsumesOnceAndRearms(t *testing.T) { | |
| 21 | + | load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil } | |
| 22 | + | s := newSkillPinState(load, nil, reviewSnapshot) | |
| 23 | + | s.arm(true, "/w", nil) | |
| 24 | + | if got := stripANSIBytes(strings.Join(s.notice(tui.Theme{}, 80), "")); !strings.Contains(got, "review (global)") { | |
| 25 | + | t.Fatalf("notice = %q", got) | |
| 26 | + | } | |
| 27 | + | first := s.consume("first") | |
| 28 | + | if !strings.Contains(first, "Inspect carefully.") || !strings.HasSuffix(first, "first") { | |
| 29 | + | t.Fatal(first) | |
| 30 | + | } | |
| 31 | + | if got := s.consume("second"); got != "second" { | |
| 32 | + | t.Fatal(got) | |
| 33 | + | } | |
| 34 | + | if len(s.notice(tui.Theme{}, 80)) != 0 { | |
| 35 | + | t.Fatal("notice survived consumption") | |
| 36 | + | } | |
| 37 | + | // /clear and /cd re-arm. | |
| 38 | + | s.arm(true, "/w", nil) | |
| 39 | + | if !strings.Contains(s.consume("after clear"), "Inspect carefully.") { | |
| 40 | + | t.Fatal("re-arm did not restore pins") | |
| 41 | + | } | |
| 42 | + | // Resume/import/fork disarm even after arming. | |
| 43 | + | s.arm(true, "/w", nil) | |
| 44 | + | s.arm(false, "/w", nil) | |
| 45 | + | if got := s.consume("resumed"); got != "resumed" { | |
| 46 | + | t.Fatal("resume reinjected pins") | |
| 47 | + | } | |
| 48 | + | // Refresh (model swap, /skills) never re-arms a consumed state. | |
| 49 | + | s.refresh("/w", nil) | |
| 50 | + | if got := s.consume("later"); got != "later" { | |
| 51 | + | t.Fatal("refresh re-armed pins") | |
| 52 | + | } | |
| 53 | + | // No loader means nothing is ever pending. | |
| 54 | + | none := newSkillPinState(nil, nil, reviewSnapshot) | |
| 55 | + | none.arm(true, "/w", nil) | |
| 56 | + | if got := none.consume("x"); got != "x" { | |
| 57 | + | t.Fatal(got) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestSkillPinStateTogglesAndSaveFailure(t *testing.T) { | |
| 62 | + | pins := skills.Pins{} | |
| 63 | + | fail := false | |
| 64 | + | flip := func(names []string, name string) []string { | |
| 65 | + | if slices.Contains(names, name) { | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | return []string{name} | |
| 69 | + | } | |
| 70 | + | toggle := func(_, name string, global bool) error { | |
| 71 | + | if fail { | |
| 72 | + | return errors.New("read-only preferences") | |
| 73 | + | } | |
| 74 | + | if global { | |
| 75 | + | pins.Global = flip(pins.Global, name) | |
| 76 | + | } else { | |
| 77 | + | pins.Project = flip(pins.Project, name) | |
| 78 | + | } | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | s := newSkillPinState(func(string) (skills.Pins, error) { return pins, nil }, toggle, reviewSnapshot) | |
| 82 | + | d := newSkillsDialog() | |
| 83 | + | d.Open(reviewSnapshot()) | |
| 84 | + | s.arm(true, "/w", d) | |
| 85 | + | ||
| 86 | + | for _, r := range []rune{'p', 'g'} { | |
| 87 | + | handled, ok, errText, _ := s.handleKey(pinKey(r), "/w", d) | |
| 88 | + | if !handled || errText != "" || ok == "" { | |
| 89 | + | t.Fatalf("key %c: handled=%v ok=%q err=%q", r, handled, ok, errText) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | if rendered := strings.Join(d.Render(tui.Theme{}, 100), "\n"); !strings.Contains(rendered, "[pg]") { | |
| 93 | + | t.Fatal(rendered) | |
| 94 | + | } | |
| 95 | + | if len(s.selected) != 1 { | |
| 96 | + | t.Fatal("scope union duplicated skill") | |
| 97 | + | } | |
| 98 | + | s.handleKey(pinKey('p'), "/w", d) | |
| 99 | + | if len(s.selected) != 1 || len(d.pins.Project) != 0 { | |
| 100 | + | t.Fatal("project unpin removed global pin") | |
| 101 | + | } | |
| 102 | + | fail = true | |
| 103 | + | handled, _, errText, _ := s.handleKey(pinKey('g'), "/w", d) | |
| 104 | + | if !handled || !strings.Contains(errText, "read-only") || len(d.pins.Global) != 1 { | |
| 105 | + | t.Fatalf("save failure not preserved: %q", errText) | |
| 106 | + | } | |
| 107 | + | // Body view and other keys are not pin keys. | |
| 108 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 109 | + | if handled, _, _, _ := s.handleKey(pinKey('g'), "/w", d); handled { | |
| 110 | + | t.Fatal("body view toggled a pin") | |
| 111 | + | } | |
| 112 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 113 | + | if handled, _, _, _ := s.handleKey(pinKey('x'), "/w", d); handled { | |
| 114 | + | t.Fatal("unrelated key handled") | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestSkillPinStateReadOnlyHost(t *testing.T) { | |
| 119 | + | s := newSkillPinState(func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, nil, reviewSnapshot) | |
| 120 | + | d := newSkillsDialog() | |
| 121 | + | d.Open(reviewSnapshot()) | |
| 122 | + | s.arm(true, "/w", d) | |
| 123 | + | if handled, _, _, _ := s.handleKey(pinKey('p'), "/w", d); handled { | |
| 124 | + | t.Fatal("pin key handled without ToggleSkillPin") | |
| 125 | + | } | |
| 126 | + | rendered := strings.Join(d.Render(tui.Theme{}, 100), "\n") | |
| 127 | + | if strings.Contains(rendered, "[-g]") || strings.Contains(rendered, "p: project pin") { | |
| 128 | + | t.Fatalf("read-only host shows pin controls: %s", rendered) | |
| 129 | + | } | |
| 130 | + | // The pending notice still informs the user. | |
| 131 | + | if len(s.notice(tui.Theme{}, 80)) == 0 { | |
| 132 | + | t.Fatal("pending notice missing") | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestSkillPinStateNoticeShowsScopesOnce(t *testing.T) { | |
| 137 | + | load := func(string) (skills.Pins, error) { | |
| 138 | + | return skills.Pins{Project: []string{"local", "shared"}, Global: []string{"everywhere", "shared"}}, nil | |
| 139 | + | } | |
| 140 | + | snap := func() []*skills.Skill { | |
| 141 | + | return []*skills.Skill{{Name: "local"}, {Name: "everywhere"}, {Name: "shared"}} | |
| 142 | + | } | |
| 143 | + | s := newSkillPinState(load, nil, snap) | |
| 144 | + | s.arm(true, "/w", nil) | |
| 145 | + | text := stripANSIBytes(strings.Join(s.notice(tui.Theme{}, 120), "\n")) | |
| 146 | + | for _, want := range []string{"local (project)", "everywhere (global)", "shared (project + global)"} { | |
| 147 | + | if !strings.Contains(text, want) { | |
| 148 | + | t.Errorf("notice missing %q: %q", want, text) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | if strings.Count(text, "shared") != 1 { | |
| 152 | + | t.Fatalf("both scopes duplicated the skill: %q", text) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestSkillPinStateWarningsAndNoticeWidth(t *testing.T) { | |
| 157 | + | load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"missing", "review"}}, nil } | |
| 158 | + | s := newSkillPinState(load, nil, reviewSnapshot) | |
| 159 | + | warnings := s.arm(true, "/w", nil) | |
| 160 | + | if len(warnings) != 1 || !strings.Contains(warnings[0], "missing") { | |
| 161 | + | t.Fatalf("warnings = %v", warnings) | |
| 162 | + | } | |
| 163 | + | bad := newSkillPinState(func(string) (skills.Pins, error) { return skills.Pins{}, errors.New("invalid skill-pins.json") }, nil, reviewSnapshot) | |
| 164 | + | if w := bad.arm(true, "/w", nil); len(w) != 1 || !strings.Contains(w[0], "invalid") { | |
| 165 | + | t.Fatalf("malformed warning = %v", w) | |
| 166 | + | } | |
| 167 | + | s.selected = []*skills.Skill{{Name: strings.Repeat("界", 30)}} | |
| 168 | + | for _, width := range []int{1, 4, 12, 30, 80} { | |
| 169 | + | for _, row := range s.notice(tui.Theme{}, width) { | |
| 170 | + | if got := runewidth.StringWidth(stripANSIBytes(row)); got > width { | |
| 171 | + | t.Fatalf("width %d exceeds %d", got, width) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
A packages/agent/modes/skill_pins_turn_test.go +432 -0
| @@ -0,0 +1,432 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 11 | + | "github.com/bnema/zut/packages/core" | |
| 12 | + | "github.com/bnema/zut/packages/provider" | |
| 13 | + | "github.com/bnema/zut/packages/tui" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // pinRecordingClient answers every request with "ok" and records the request. | |
| 17 | + | type pinRecordingClient struct { | |
| 18 | + | requests chan provider.Request | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (c *pinRecordingClient) Name() string { return "pin-test" } | |
| 22 | + | ||
| 23 | + | func (c *pinRecordingClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 24 | + | c.requests <- req | |
| 25 | + | out := make(chan provider.Event, 2) | |
| 26 | + | out <- provider.EventTextDelta{Delta: "ok"} | |
| 27 | + | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |
| 28 | + | Role: provider.RoleAssistant, | |
| 29 | + | Content: []provider.Content{provider.TextBlock{Text: "ok"}}, | |
| 30 | + | }} | |
| 31 | + | close(out) | |
| 32 | + | return out, nil | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func lastUserText(req provider.Request) string { | |
| 36 | + | for n := len(req.Messages) - 1; n >= 0; n-- { | |
| 37 | + | if req.Messages[n].Role != provider.RoleUser { | |
| 38 | + | continue | |
| 39 | + | } | |
| 40 | + | return stripMessageTime(userMessageText(req.Messages[n])) | |
| 41 | + | } | |
| 42 | + | return "" | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // stripMessageTime drops the request-time "[message time: ...]" line the | |
| 46 | + | // context builder prefixes to user messages. | |
| 47 | + | func stripMessageTime(text string) string { | |
| 48 | + | if strings.HasPrefix(text, "[message time:") { | |
| 49 | + | if _, rest, ok := strings.Cut(text, "\n"); ok { | |
| 50 | + | return rest | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | return text | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | type pinTurnHarness struct { | |
| 57 | + | i *Interactive | |
| 58 | + | client *pinRecordingClient | |
| 59 | + | mu sync.Mutex | |
| 60 | + | persisted []provider.Message | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func newPinTurnHarness(t *testing.T, pinned bool) *pinTurnHarness { | |
| 64 | + | t.Helper() | |
| 65 | + | h := &pinTurnHarness{client: &pinRecordingClient{requests: make(chan provider.Request, 8)}} | |
| 66 | + | ag := core.NewAgent(h.client, "test-model", "", nil) | |
| 67 | + | ag.OnMessageAppended = func(m provider.Message) { | |
| 68 | + | h.mu.Lock() | |
| 69 | + | h.persisted = append(h.persisted, m) | |
| 70 | + | h.mu.Unlock() | |
| 71 | + | } | |
| 72 | + | h.i = NewInteractive(InteractiveConfig{ | |
| 73 | + | Agent: ag, | |
| 74 | + | Provider: "anthropic", | |
| 75 | + | Model: "claude-sonnet-4-5-20250929", | |
| 76 | + | CWD: "/work", | |
| 77 | + | SkillSnapshot: func() []*skills.Skill { | |
| 78 | + | return []*skills.Skill{{Name: "review", Body: "Inspect carefully."}} | |
| 79 | + | }, | |
| 80 | + | LoadSkillPins: func(string) (skills.Pins, error) { | |
| 81 | + | return skills.Pins{Project: []string{"review"}}, nil | |
| 82 | + | }, | |
| 83 | + | PreloadPinnedSkills: pinned, | |
| 84 | + | }) | |
| 85 | + | h.i.runCtx = context.Background() | |
| 86 | + | return h | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // prompt submits text as genuine user input, the way the host paths do. | |
| 90 | + | func (h *pinTurnHarness) prompt(t *testing.T, text string) provider.Request { | |
| 91 | + | t.Helper() | |
| 92 | + | h.i.submitOrQueueMessage(core.QueuedMessage{Text: text}, true) | |
| 93 | + | req := receiveRequest(t, h.client.requests) | |
| 94 | + | waitInteractiveIdle(t, h.i) | |
| 95 | + | return req | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (h *pinTurnHarness) persistedUserTexts() []string { | |
| 99 | + | h.mu.Lock() | |
| 100 | + | defer h.mu.Unlock() | |
| 101 | + | var out []string | |
| 102 | + | for _, m := range h.persisted { | |
| 103 | + | if m.Role == provider.RoleUser { | |
| 104 | + | out = append(out, userMessageText(m)) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | return out | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestPinnedSkillsJoinFirstPromptOnceAndPersist(t *testing.T) { | |
| 111 | + | h := newPinTurnHarness(t, true) | |
| 112 | + | first := lastUserText(h.prompt(t, "first request")) | |
| 113 | + | if strings.Count(first, "Inspect carefully.") != 1 || !strings.HasSuffix(first, "first request") { | |
| 114 | + | t.Fatalf("first prompt = %q", first) | |
| 115 | + | } | |
| 116 | + | if second := lastUserText(h.prompt(t, "second request")); second != "second request" { | |
| 117 | + | t.Fatalf("second prompt carried pins: %q", second) | |
| 118 | + | } | |
| 119 | + | texts := h.persistedUserTexts() | |
| 120 | + | if len(texts) != 2 || strings.Count(strings.Join(texts, "\n"), "Inspect carefully.") != 1 || !strings.Contains(texts[0], "Inspect carefully.") { | |
| 121 | + | t.Fatalf("persisted transcript = %q", texts) | |
| 122 | + | } | |
| 123 | + | // The notice disappears once consumed. | |
| 124 | + | h.i.mu.Lock() | |
| 125 | + | notice := h.i.skillPins.notice(h.i.cfg.Theme, 80) | |
| 126 | + | h.i.mu.Unlock() | |
| 127 | + | if len(notice) != 0 { | |
| 128 | + | t.Fatal("notice survived consumption") | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestPinnedSkillsAbsentWithoutArming(t *testing.T) { | |
| 133 | + | for name, arm := range map[string]func(*pinTurnHarness){ | |
| 134 | + | "not configured": func(*pinTurnHarness) {}, | |
| 135 | + | "resumed": func(h *pinTurnHarness) { | |
| 136 | + | h.i.SetPinnedSkillsPending(true) | |
| 137 | + | h.i.SetPinnedSkillsPending(false) | |
| 138 | + | }, | |
| 139 | + | "session applied": func(h *pinTurnHarness) { | |
| 140 | + | h.i.SetPinnedSkillsPending(true) | |
| 141 | + | h.i.ApplySessionAgent(core.NewAgent(h.client, "test-model", "", nil), "anthropic", "claude-sonnet-4-5-20250929") | |
| 142 | + | }, | |
| 143 | + | } { | |
| 144 | + | t.Run(name, func(t *testing.T) { | |
| 145 | + | h := newPinTurnHarness(t, false) | |
| 146 | + | arm(h) | |
| 147 | + | if got := lastUserText(h.prompt(t, "hello")); got != "hello" { | |
| 148 | + | t.Fatalf("prompt = %q", got) | |
| 149 | + | } | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | const pinBody = "Inspect carefully." | |
| 155 | + | ||
| 156 | + | func (h *pinTurnHarness) pinsPending() bool { | |
| 157 | + | h.i.mu.Lock() | |
| 158 | + | defer h.i.mu.Unlock() | |
| 159 | + | return h.i.skillPins.pending | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestPinnedSkillsIgnoreNonUserStarts(t *testing.T) { | |
| 163 | + | h := newPinTurnHarness(t, true) | |
| 164 | + | ||
| 165 | + | // Scheduler follow-up, extension prompt and host evidence are not user | |
| 166 | + | // input: they must neither carry the pins nor consume them. | |
| 167 | + | if err := h.i.SubmitFollowUp(context.Background(), "scheduled tick"); err != nil { | |
| 168 | + | t.Fatal(err) | |
| 169 | + | } | |
| 170 | + | if got := lastUserText(receiveRequest(t, h.client.requests)); got != "scheduled tick" { | |
| 171 | + | t.Fatalf("scheduled prompt = %q", got) | |
| 172 | + | } | |
| 173 | + | waitInteractiveIdle(t, h.i) | |
| 174 | + | ||
| 175 | + | h.i.startTurn(context.Background(), "extension prompt") // invokeExtensionCommand "prompt" action | |
| 176 | + | if got := lastUserText(receiveRequest(t, h.client.requests)); got != "extension prompt" { | |
| 177 | + | t.Fatalf("extension prompt = %q", got) | |
| 178 | + | } | |
| 179 | + | waitInteractiveIdle(t, h.i) | |
| 180 | + | ||
| 181 | + | h.i.submitOrQueueMessage(core.QueuedMessage{Text: "worker report", HostEvent: true}, false) | |
| 182 | + | if got := lastUserText(receiveRequest(t, h.client.requests)); got != "worker report" { | |
| 183 | + | t.Fatalf("host event = %q", got) | |
| 184 | + | } | |
| 185 | + | waitInteractiveIdle(t, h.i) | |
| 186 | + | ||
| 187 | + | if !h.pinsPending() { | |
| 188 | + | t.Fatal("non-user starts consumed the pins") | |
| 189 | + | } | |
| 190 | + | if first := lastUserText(h.prompt(t, "real prompt")); strings.Count(first, pinBody) != 1 { | |
| 191 | + | t.Fatalf("first genuine user prompt = %q", first) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestPinnedSkillsKeepContinuationsAndRetriesPending(t *testing.T) { | |
| 196 | + | h := newPinTurnHarness(t, true) | |
| 197 | + | // Real continuation and post-compaction/overflow retry entry points. | |
| 198 | + | h.i.agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier"}}}}) | |
| 199 | + | h.i.startTurnRequest(context.Background(), "", nil, true, false) | |
| 200 | + | receiveRequest(t, h.client.requests) | |
| 201 | + | waitInteractiveIdle(t, h.i) | |
| 202 | + | h.i.startTurnRequest(context.Background(), "", nil, true, true) | |
| 203 | + | receiveRequest(t, h.client.requests) | |
| 204 | + | waitInteractiveIdle(t, h.i) | |
| 205 | + | if !h.pinsPending() { | |
| 206 | + | t.Fatal("continuation or retry consumed the pins") | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func TestPinnedSkillsModelSwapAndClear(t *testing.T) { | |
| 211 | + | h := newPinTurnHarness(t, true) | |
| 212 | + | h.i.swapModel("anthropic", "claude-opus-4-1-20250805", nil, false) | |
| 213 | + | if !h.pinsPending() { | |
| 214 | + | t.Fatal("model swap consumed the pins") | |
| 215 | + | } | |
| 216 | + | if first := lastUserText(h.prompt(t, "real prompt")); strings.Count(first, pinBody) != 1 { | |
| 217 | + | t.Fatalf("pins lost across model swap: %q", first) | |
| 218 | + | } | |
| 219 | + | h.i.swapModel("anthropic", "claude-sonnet-4-5-20250929", nil, false) | |
| 220 | + | if later := lastUserText(h.prompt(t, "later")); later != "later" { | |
| 221 | + | t.Fatalf("model swap re-armed pins: %q", later) | |
| 222 | + | } | |
| 223 | + | h.i.runSlash(context.Background(), "/clear") | |
| 224 | + | if again := lastUserText(h.prompt(t, "after clear")); strings.Count(again, pinBody) != 1 { | |
| 225 | + | t.Fatalf("/clear did not re-arm pins: %q", again) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // A prompt typed while a startup command still owns the agent is queued, not | |
| 230 | + | // started. It is the first genuine user prompt and must carry the pins once. | |
| 231 | + | func TestPinnedSkillsQueuedDuringStartupPreReachProvider(t *testing.T) { | |
| 232 | + | h := newPinTurnHarness(t, true) | |
| 233 | + | h.i.mu.Lock() | |
| 234 | + | h.i.busy = true | |
| 235 | + | h.i.awaitingStartupPre = true | |
| 236 | + | h.i.mu.Unlock() | |
| 237 | + | ||
| 238 | + | h.i.ed.SetValue("typed while pre runs") | |
| 239 | + | h.i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |
| 240 | + | h.i.ed.SetValue("second typed") | |
| 241 | + | h.i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |
| 242 | + | ||
| 243 | + | queued := h.i.agent.PendingQueuedMessages() | |
| 244 | + | if len(queued) != 2 || strings.Count(queued[0].Text, pinBody) != 1 || !strings.HasSuffix(queued[0].Text, "typed while pre runs") || queued[1].Text != "second typed" { | |
| 245 | + | t.Fatalf("queued = %+v", queued) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // The startup turn ends; the agent loop delivers the queued prompts. | |
| 249 | + | h.i.mu.Lock() | |
| 250 | + | h.i.busy = false | |
| 251 | + | h.i.awaitingStartupPre = false | |
| 252 | + | h.i.mu.Unlock() | |
| 253 | + | h.i.startTurnRequest(context.Background(), "", nil, true, false) | |
| 254 | + | req := receiveRequest(t, h.client.requests) | |
| 255 | + | waitInteractiveIdle(t, h.i) | |
| 256 | + | if strings.Count(userMessagesText(req), pinBody) != 1 { | |
| 257 | + | t.Fatalf("provider request = %q", userMessagesText(req)) | |
| 258 | + | } | |
| 259 | + | if strings.Count(strings.Join(h.persistedUserTexts(), "\n"), pinBody) != 1 { | |
| 260 | + | t.Fatalf("persisted = %q", h.persistedUserTexts()) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestPinnedSkillsCarryOncePastPreTurnCompaction(t *testing.T) { | |
| 265 | + | h := newPinTurnHarness(t, true) | |
| 266 | + | h.i.agent.SetMessages([]provider.Message{ | |
| 267 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier one"}}}, | |
| 268 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply one"}}}, | |
| 269 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier two"}}}, | |
| 270 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply two"}}}, | |
| 271 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier three"}}}, | |
| 272 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply three"}}}, | |
| 273 | + | }) | |
| 274 | + | threshold := 70 | |
| 275 | + | h.i.cfg.AutoCompactThreshold = &threshold | |
| 276 | + | h.i.mu.Lock() | |
| 277 | + | h.i.lastCtxInput = 10_000_000 | |
| 278 | + | h.i.mu.Unlock() | |
| 279 | + | ||
| 280 | + | h.i.submitOrQueueMessage(core.QueuedMessage{Text: "big request"}, true) | |
| 281 | + | compaction := receiveRequest(t, h.client.requests) | |
| 282 | + | if strings.Contains(userMessagesText(compaction), pinBody) { | |
| 283 | + | t.Fatal("compaction request carried pinned skills") | |
| 284 | + | } | |
| 285 | + | h.i.mu.Lock() | |
| 286 | + | h.i.lastCtxInput = 0 | |
| 287 | + | h.i.mu.Unlock() | |
| 288 | + | followUp := receiveRequest(t, h.client.requests) | |
| 289 | + | waitInteractiveIdle(t, h.i) | |
| 290 | + | if strings.Count(userMessagesText(followUp), pinBody) != 1 { | |
| 291 | + | t.Fatalf("follow-up did not carry pins exactly once: %q", userMessagesText(followUp)) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func TestPinnedSkillsClearResetsCompactionEstimate(t *testing.T) { | |
| 296 | + | h := newPinTurnHarness(t, true) | |
| 297 | + | h.prompt(t, "before clear") | |
| 298 | + | h.i.mu.Lock() | |
| 299 | + | h.i.lastCtxInput = 10_000_000 | |
| 300 | + | h.i.mu.Unlock() | |
| 301 | + | h.i.runSlash(context.Background(), "/clear") | |
| 302 | + | if got := lastUserText(h.prompt(t, "after clear")); strings.Count(got, pinBody) != 1 || !strings.HasSuffix(got, "after clear") { | |
| 303 | + | t.Fatalf("first request after clear = %q", got) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func TestPinnedSkillsRearmAfterFailedPreTurnCompaction(t *testing.T) { | |
| 308 | + | h := newPinTurnHarness(t, true) | |
| 309 | + | threshold := 70 | |
| 310 | + | h.i.cfg.AutoCompactThreshold = &threshold | |
| 311 | + | h.i.mu.Lock() | |
| 312 | + | h.i.lastCtxInput = 10_000_000 | |
| 313 | + | h.i.mu.Unlock() | |
| 314 | + | // An empty transcript cannot compact: the pending user prompt never reaches | |
| 315 | + | // the provider, and its pinned selection must be released for a retry. | |
| 316 | + | h.i.submitOrQueueMessage(core.QueuedMessage{Text: "undelivered request"}, true) | |
| 317 | + | waitInteractiveIdle(t, h.i) | |
| 318 | + | h.i.mu.Lock() | |
| 319 | + | errText := h.i.statusErr | |
| 320 | + | h.i.lastCtxInput = 0 | |
| 321 | + | h.i.mu.Unlock() | |
| 322 | + | if !strings.Contains(errText, "nothing to compact") || !h.pinsPending() { | |
| 323 | + | t.Fatalf("failed compaction: status=%q pending=%v", errText, h.pinsPending()) | |
| 324 | + | } | |
| 325 | + | select { | |
| 326 | + | case req := <-h.client.requests: | |
| 327 | + | t.Fatalf("failed compaction sent unexpected request: %q", userMessagesText(req)) | |
| 328 | + | default: | |
| 329 | + | } | |
| 330 | + | if got := lastUserText(h.prompt(t, "retried request")); strings.Count(got, pinBody) != 1 || !strings.HasSuffix(got, "retried request") { | |
| 331 | + | t.Fatalf("retry after failed compaction = %q", got) | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func TestPinnedSkillsRearmAfterCancelledPreTurnCompaction(t *testing.T) { | |
| 336 | + | client := &compactQueueClient{ | |
| 337 | + | compactionStarted: make(chan struct{}), releaseCompaction: make(chan struct{}), | |
| 338 | + | followUpRequest: make(chan provider.Request, 1), | |
| 339 | + | } | |
| 340 | + | ag := core.NewAgent(client, "test-model", "", nil) | |
| 341 | + | ag.SetMessages([]provider.Message{ | |
| 342 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier context"}}}, | |
| 343 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "earlier answer"}}}, | |
| 344 | + | }) | |
| 345 | + | threshold := 70 | |
| 346 | + | i := NewInteractive(InteractiveConfig{ | |
| 347 | + | Agent: ag, Provider: "anthropic", Model: "claude-sonnet-4-5-20250929", CWD: t.TempDir(), | |
| 348 | + | AutoCompactThreshold: &threshold, PreloadPinnedSkills: true, | |
| 349 | + | LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, | |
| 350 | + | SkillSnapshot: reviewSnapshot, | |
| 351 | + | }) | |
| 352 | + | i.runCtx = context.Background() | |
| 353 | + | t.Cleanup(i.CancelTurn) | |
| 354 | + | i.mu.Lock() | |
| 355 | + | i.lastCtxInput = 150000 | |
| 356 | + | i.mu.Unlock() | |
| 357 | + | i.submitOrQueueMessage(core.QueuedMessage{Text: "undelivered request"}, true) | |
| 358 | + | select { | |
| 359 | + | case <-client.compactionStarted: | |
| 360 | + | case <-time.After(2 * time.Second): | |
| 361 | + | t.Fatal("compaction did not start") | |
| 362 | + | } | |
| 363 | + | i.mu.Lock() | |
| 364 | + | cancel := i.cancelTurn | |
| 365 | + | i.mu.Unlock() | |
| 366 | + | if cancel == nil { | |
| 367 | + | t.Fatal("compaction has no cancellation") | |
| 368 | + | } | |
| 369 | + | cancel() | |
| 370 | + | waitInteractiveIdle(t, i) | |
| 371 | + | i.mu.Lock() | |
| 372 | + | pending := i.skillPins.pending | |
| 373 | + | i.lastCtxInput = 0 | |
| 374 | + | i.mu.Unlock() | |
| 375 | + | if !pending { | |
| 376 | + | t.Fatal("cancelled compaction lost the pinned selection") | |
| 377 | + | } | |
| 378 | + | i.submitOrQueueMessage(core.QueuedMessage{Text: "retried request"}, true) | |
| 379 | + | select { | |
| 380 | + | case req := <-client.followUpRequest: | |
| 381 | + | if got := lastUserText(req); strings.Count(got, "Inspect carefully.") != 1 || !strings.HasSuffix(got, "retried request") { | |
| 382 | + | t.Fatalf("retry after cancelled compaction = %q", got) | |
| 383 | + | } | |
| 384 | + | case <-time.After(2 * time.Second): | |
| 385 | + | t.Fatal("retry did not reach provider") | |
| 386 | + | } | |
| 387 | + | waitInteractiveIdle(t, i) | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func userMessagesText(req provider.Request) string { | |
| 391 | + | var parts []string | |
| 392 | + | for _, m := range req.Messages { | |
| 393 | + | if m.Role == provider.RoleUser { | |
| 394 | + | parts = append(parts, userMessageText(m)) | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | return strings.Join(parts, "\n") | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | func TestSkillsPickerPinKeysUseHostCallbacksAndDedupeWarnings(t *testing.T) { | |
| 401 | + | var toggled []string | |
| 402 | + | i := NewInteractive(InteractiveConfig{ | |
| 403 | + | CWD: "/work", | |
| 404 | + | SkillSnapshot: reviewSnapshot, | |
| 405 | + | LoadSkillPins: func(string) (skills.Pins, error) { | |
| 406 | + | return skills.Pins{Global: []string{"gone"}}, nil | |
| 407 | + | }, | |
| 408 | + | ToggleSkillPin: func(cwd, name string, global bool) error { | |
| 409 | + | toggled = append(toggled, cwd+":"+name) | |
| 410 | + | return nil | |
| 411 | + | }, | |
| 412 | + | PreloadPinnedSkills: true, | |
| 413 | + | }) | |
| 414 | + | i.openSkillsDialog() | |
| 415 | + | i.openSkillsDialog() | |
| 416 | + | i.handleKey(context.Background(), pinKey('p')) | |
| 417 | + | if len(toggled) != 1 || toggled[0] != "/work:review" { | |
| 418 | + | t.Fatalf("toggle calls = %v", toggled) | |
| 419 | + | } | |
| 420 | + | i.mu.Lock() | |
| 421 | + | warnings := 0 | |
| 422 | + | for _, e := range i.reloadErrors { | |
| 423 | + | if strings.Contains(e, "gone") { | |
| 424 | + | warnings++ | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | notice := i.statusOK | |
| 428 | + | i.mu.Unlock() | |
| 429 | + | if warnings != 1 || notice == "" { | |
| 430 | + | t.Fatalf("warnings=%d status=%q reloadErrors=%q", warnings, notice, i.reloadErrors) | |
| 431 | + | } | |
| 432 | + | } | |
M packages/agent/modes/skills_dialog.go +37 -4
| @@ -8,11 +8,14 @@ import ( | |||
| 8 | 8 | "github.com/bnema/zut/packages/tui" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | // skillsDialog lists every discovered skill and lets the user view | |
| 12 | - | // the body of one inline. View is read-only — the model loads skills | |
| 13 | - | // itself via the `skill` tool. This dialog is for inspection. | |
| 11 | + | // skillsDialog lists every discovered skill, shows its pin scopes, and lets | |
| 12 | + | // the user view the body of one inline. Viewing is read-only — the model loads | |
| 13 | + | // skills itself via the `skill` tool. Pin persistence is handled by the | |
| 14 | + | // interactive host (see skillPinState); the dialog only renders the markers. | |
| 14 | 15 | type skillsDialog struct { | |
| 15 | 16 | active bool | |
| 17 | + | pins skills.Pins // last pins loaded by the host, for the [pg] markers | |
| 18 | + | canPin bool // host can save pins; hides markers and the hint otherwise | |
| 16 | 19 | skills []*skills.Skill | |
| 17 | 20 | cursor int | |
| 18 | 21 | viewing *skills.Skill // when non-nil, render the body instead of the list | |
| @@ -99,6 +102,9 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||
| 99 | 102 | } | |
| 100 | 103 | ||
| 101 | 104 | out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)} | |
| 105 | + | if d.canPin { | |
| 106 | + | out = append(out, " "+th.FGColor(th.Muted, "p: project pin, g: global pin (toggle)")) | |
| 107 | + | } | |
| 102 | 108 | if len(d.skills) == 0 { | |
| 103 | 109 | out = append(out, " "+th.FGColor(th.Muted, "no user skills loaded")) | |
| 104 | 110 | out = append(out, " "+th.FGColor(th.Muted, "add SKILL.md under $ZUT_HOME/skills, .zut/skills, .claude/skills, or .agents/skills")) | |
| @@ -113,7 +119,8 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||
| 113 | 119 | } | |
| 114 | 120 | for i := start; i < end; i++ { | |
| 115 | 121 | s := d.skills[i] | |
| 116 | - | row := formatSkillRow(s, width-2) | |
| 122 | + | marker := d.pinMarker(s) | |
| 123 | + | row := marker + formatSkillRow(s, width-2-len(marker)) | |
| 117 | 124 | if i == d.cursor { | |
| 118 | 125 | out = append(out, th.PadHighlight(" "+row, width)) | |
| 119 | 126 | } else { | |
| @@ -128,6 +135,32 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||
| 128 | 135 | return out | |
| 129 | 136 | } | |
| 130 | 137 | ||
| 138 | + | // pinMarker returns "[pg] "-style scope markers, or "" when pinning is | |
| 139 | + | // unavailable (for example a host without pin storage). | |
| 140 | + | func (d *skillsDialog) pinMarker(s *skills.Skill) string { | |
| 141 | + | if !d.canPin { | |
| 142 | + | return "" | |
| 143 | + | } | |
| 144 | + | project, global := "-", "-" | |
| 145 | + | projectPinned, globalPinned := d.pins.Scopes(d.skills, s) | |
| 146 | + | if projectPinned { | |
| 147 | + | project = "p" | |
| 148 | + | } | |
| 149 | + | if globalPinned { | |
| 150 | + | global = "g" | |
| 151 | + | } | |
| 152 | + | return "[" + project + global + "] " | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // selectedSkill returns the skill under the list cursor, or nil in the body | |
| 156 | + | // view or an empty list. | |
| 157 | + | func (d *skillsDialog) selectedSkill() *skills.Skill { | |
| 158 | + | if !d.Active() || d.viewing != nil || d.cursor < 0 || d.cursor >= len(d.skills) { | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | return d.skills[d.cursor] | |
| 162 | + | } | |
| 163 | + | ||
| 131 | 164 | func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | |
| 132 | 165 | s := d.viewing | |
| 133 | 166 | out := []string{ | |
M packages/agent/modes/slash_suggest.go +33 -6
| @@ -59,7 +59,7 @@ var slashCatalog = []slashCommand{ | |||
| 59 | 59 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 60 | 60 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | |
| 61 | 61 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | |
| 62 | - | {Name: "/session", Desc: "export/import, fork, or browse the current session tree"}, | |
| 62 | + | {Name: "/session", Desc: "inspect, export, import, fork, or browse the current session"}, | |
| 63 | 63 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | |
| 64 | 64 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | |
| 65 | 65 | {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"}, | |
| @@ -75,6 +75,14 @@ var slashCatalog = []slashCommand{ | |||
| 75 | 75 | {Name: "/exit", Desc: "exit zut"}, | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | var sessionSlashCommands = []slashCommand{ | |
| 79 | + | {Name: "/session timeline", Desc: "inspect context, messages, and tool calls"}, | |
| 80 | + | {Name: "/session export", Desc: "write the current session to a .zutsession file"}, | |
| 81 | + | {Name: "/session import", Desc: "load a .zutsession file into this directory"}, | |
| 82 | + | {Name: "/session fork", Desc: "branch from a past user message"}, | |
| 83 | + | {Name: "/session tree", Desc: "switch between session branches"}, | |
| 84 | + | } | |
| 85 | + | ||
| 78 | 86 | // slashSuggester renders the popup that appears when the editor starts | |
| 79 | 87 | // with "/". It does not own any input state — the editor drives. | |
| 80 | 88 | const slashSuggestPageSize = 8 | |
| @@ -285,15 +293,26 @@ func newSlashSuggester() *slashSuggester { return &slashSuggester{} } | |||
| 285 | 293 | // matches returns the commands whose name has input as a prefix. | |
| 286 | 294 | // If input is just "/", everything is shown. | |
| 287 | 295 | func (s *slashSuggester) matches(input string) []slashCommand { | |
| 288 | - | input = strings.TrimRight(input, " ") | |
| 289 | 296 | if input == "" || !strings.HasPrefix(input, "/") { | |
| 290 | 297 | return nil | |
| 291 | 298 | } | |
| 292 | - | // If there is a space, the user has moved past the command name. | |
| 293 | - | if idx := strings.IndexByte(input, ' '); idx >= 0 { | |
| 299 | + | matchPrefix := strings.ToLower(input) | |
| 300 | + | // /session exposes its actions after the first space. | |
| 301 | + | if strings.HasPrefix(matchPrefix, "/session ") { | |
| 302 | + | var out []slashCommand | |
| 303 | + | for _, c := range sessionSlashCommands { | |
| 304 | + | if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) { | |
| 305 | + | out = append(out, c) | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | return out | |
| 309 | + | } | |
| 310 | + | // Any other command: a space means the user has moved on to its arguments. | |
| 311 | + | input = strings.TrimRight(input, " ") | |
| 312 | + | if strings.ContainsRune(input, ' ') { | |
| 294 | 313 | return nil | |
| 295 | 314 | } | |
| 296 | - | matchPrefix := strings.ToLower(input) | |
| 315 | + | matchPrefix = strings.ToLower(input) | |
| 297 | 316 | catalog := s.allCatalog() | |
| 298 | 317 | if strings.HasPrefix(matchPrefix, skillCommandPrefix) { | |
| 299 | 318 | catalog = s.skills | |
| @@ -311,7 +330,15 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||
| 311 | 330 | out = append(out, c) | |
| 312 | 331 | } | |
| 313 | 332 | } | |
| 314 | - | return pruneOrphanHeaders(out) | |
| 333 | + | out = pruneOrphanHeaders(out) | |
| 334 | + | // Prefer an exact command over longer commands sharing its prefix so | |
| 335 | + | // /session stays selected instead of the earlier /sessions entry. | |
| 336 | + | for _, c := range out { | |
| 337 | + | if !c.Header && strings.EqualFold(c.Name, input) { | |
| 338 | + | return []slashCommand{c} | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | return out | |
| 315 | 342 | } | |
| 316 | 343 | ||
| 317 | 344 | // pruneOrphanHeaders removes header rows that have no commands | |
M packages/agent/modes/slash_suggest_test.go +32 -0
| @@ -64,6 +64,38 @@ func TestSlashSuggesterHasOrchestratorToggle(t *testing.T) { | |||
| 64 | 64 | } | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | func TestSlashSuggesterPrefersExactSessionCommand(t *testing.T) { | |
| 68 | + | s := newSlashSuggester() | |
| 69 | + | if got := commandNames(s.matches("/session")); !slices.Equal(got, []string{"/session"}) { | |
| 70 | + | t.Fatalf("matches = %v, want exact /session", got) | |
| 71 | + | } | |
| 72 | + | if got := s.Selection("/session"); got != "/session" { | |
| 73 | + | t.Fatalf("selection = %q, want /session", got) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestSlashSuggesterCompletesSessionActions(t *testing.T) { | |
| 78 | + | s := newSlashSuggester() | |
| 79 | + | if got, want := commandNames(s.matches("/session ")), []string{ | |
| 80 | + | "/session timeline", | |
| 81 | + | "/session export", | |
| 82 | + | "/session import", | |
| 83 | + | "/session fork", | |
| 84 | + | "/session tree", | |
| 85 | + | }; !slices.Equal(got, want) { | |
| 86 | + | t.Fatalf("matches = %v, want %v", got, want) | |
| 87 | + | } | |
| 88 | + | if got := commandNames(s.matches("/session t")); !slices.Equal(got, []string{"/session timeline", "/session tree"}) { | |
| 89 | + | t.Fatalf("filtered matches = %v", got) | |
| 90 | + | } | |
| 91 | + | if got := s.Selection("/session t"); got != "/session timeline" { | |
| 92 | + | t.Fatalf("selection = %q, want /session timeline", got) | |
| 93 | + | } | |
| 94 | + | if got := s.matches("/model x"); len(got) != 0 { | |
| 95 | + | t.Fatalf("arguments of other commands must not suggest: %v", got) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 67 | 99 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | |
| 68 | 100 | s := newSlashSuggester() | |
| 69 | 101 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | |
A packages/agent/modes/timeline_view.go +888 -0
| @@ -0,0 +1,888 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "hash/fnv" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | "unicode" | |
| 12 | + | ||
| 13 | + | "github.com/mattn/go-runewidth" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/zut/packages/core" | |
| 16 | + | "github.com/bnema/zut/packages/provider" | |
| 17 | + | "github.com/bnema/zut/packages/tui" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // The session timeline is a read-only inspector for the model context: the | |
| 21 | + | // system prompt, the visible transcript, and every tool call with its result. | |
| 22 | + | // It renders inside the shared floating pane, so the live transcript keeps | |
| 23 | + | // updating behind it. | |
| 24 | + | // | |
| 25 | + | // Privacy: entries are built only from the transcript snapshot. Encrypted | |
| 26 | + | // reasoning, provider thought signatures and image bytes are never rendered | |
| 27 | + | // or exported, and nothing here logs. | |
| 28 | + | ||
| 29 | + | const timelineTabCount = 5 | |
| 30 | + | ||
| 31 | + | var timelineTabNames = [timelineTabCount]string{"summary", "payload", "result", "schema", "timing"} | |
| 32 | + | ||
| 33 | + | type timelineEntry struct { | |
| 34 | + | Kind string `json:"kind"` | |
| 35 | + | Turn int `json:"turn,omitempty"` | |
| 36 | + | Label string `json:"label"` | |
| 37 | + | Summary string `json:"summary"` | |
| 38 | + | Time time.Time `json:"time,omitempty"` | |
| 39 | + | Duration string `json:"duration,omitempty"` | |
| 40 | + | Payload string `json:"payload,omitempty"` | |
| 41 | + | Result string `json:"result,omitempty"` | |
| 42 | + | Schema string `json:"schema,omitempty"` | |
| 43 | + | IsError bool `json:"is_error,omitempty"` | |
| 44 | + | ToolCall string `json:"tool_call_id,omitempty"` | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // timelineCacheKey identifies an unchanged snapshot: the agent, its transcript | |
| 48 | + | // revision, and a content hash of the system prompt and tool specs (which | |
| 49 | + | // change without bumping the transcript revision). The zero value disables | |
| 50 | + | // caching (used by tests and callers without a live agent). | |
| 51 | + | type timelineCacheKey struct { | |
| 52 | + | agent *core.Agent | |
| 53 | + | rev uint64 | |
| 54 | + | prompt uint64 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // timelineEstimates are the token estimates shown in the context header. They | |
| 58 | + | // depend only on the snapshot, so they are computed once per snapshot. | |
| 59 | + | type timelineEstimates struct { | |
| 60 | + | system, tools, messages int | |
| 61 | + | ok bool | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | type timelineData struct { | |
| 65 | + | System string | |
| 66 | + | Tools []provider.Tool | |
| 67 | + | Messages []provider.Message | |
| 68 | + | ContextUsed int | |
| 69 | + | ContextMax int | |
| 70 | + | Key timelineCacheKey | |
| 71 | + | Est timelineEstimates | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | type timelineView struct { | |
| 75 | + | active bool | |
| 76 | + | cursor int | |
| 77 | + | tab int | |
| 78 | + | detailTop int | |
| 79 | + | searching bool | |
| 80 | + | filter string | |
| 81 | + | notice string | |
| 82 | + | ||
| 83 | + | // snap is the last complete snapshot; it is reused while snapKey matches | |
| 84 | + | // so an idle redraw copies neither the transcript nor the tool specs. | |
| 85 | + | snapValid bool | |
| 86 | + | snapKey timelineCacheKey | |
| 87 | + | snap timelineData | |
| 88 | + | ||
| 89 | + | cacheValid bool | |
| 90 | + | cacheKey timelineCacheKey | |
| 91 | + | cacheAll []timelineEntry | |
| 92 | + | cacheFilter string | |
| 93 | + | cacheList []timelineEntry | |
| 94 | + | cacheListOK bool | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | type timelineAction struct { | |
| 98 | + | Export bool | |
| 99 | + | Close bool | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func newTimelineView() *timelineView { return &timelineView{} } | |
| 103 | + | ||
| 104 | + | func (v *timelineView) Active() bool { return v != nil && v.active } | |
| 105 | + | ||
| 106 | + | // entries returns the (optionally filtered) timeline rows for data, reusing | |
| 107 | + | // the previous build while the transcript revision is unchanged. | |
| 108 | + | func (v *timelineView) entries(data timelineData) []timelineEntry { | |
| 109 | + | if data.Key == (timelineCacheKey{}) || !v.cacheValid || v.cacheKey != data.Key { | |
| 110 | + | v.cacheAll = buildTimelineEntries(data) | |
| 111 | + | v.cacheKey = data.Key | |
| 112 | + | v.cacheValid = data.Key != (timelineCacheKey{}) | |
| 113 | + | v.cacheListOK = false | |
| 114 | + | } | |
| 115 | + | if !v.cacheListOK || v.cacheFilter != v.filter { | |
| 116 | + | v.cacheList = filterTimelineEntries(v.cacheAll, v.filter) | |
| 117 | + | v.cacheFilter = v.filter | |
| 118 | + | v.cacheListOK = true | |
| 119 | + | } | |
| 120 | + | return v.cacheList | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (v *timelineView) Open(data timelineData) { | |
| 124 | + | v.active = true | |
| 125 | + | v.searching = false | |
| 126 | + | v.filter = "" | |
| 127 | + | v.notice = "" | |
| 128 | + | v.tab = 0 | |
| 129 | + | v.detailTop = 0 | |
| 130 | + | v.cacheValid = false | |
| 131 | + | v.cursor = len(v.entries(data)) - 1 | |
| 132 | + | if v.cursor < 0 { | |
| 133 | + | v.cursor = 0 | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (v *timelineView) Close() { | |
| 138 | + | v.active = false | |
| 139 | + | v.searching = false | |
| 140 | + | v.filter = "" | |
| 141 | + | v.notice = "" | |
| 142 | + | v.detailTop = 0 | |
| 143 | + | v.cacheValid = false | |
| 144 | + | v.cacheAll, v.cacheList = nil, nil | |
| 145 | + | v.cacheListOK = false | |
| 146 | + | v.snapValid = false | |
| 147 | + | v.snap = timelineData{} | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (v *timelineView) SetNotice(s string) { v.notice = s } | |
| 151 | + | ||
| 152 | + | func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction { | |
| 153 | + | entries := v.entries(data) | |
| 154 | + | clampTimelineCursor(v, len(entries)) | |
| 155 | + | ||
| 156 | + | previousCursor, previousTab := v.cursor, v.tab | |
| 157 | + | switch k.Kind { | |
| 158 | + | case tui.KeyLeft: | |
| 159 | + | if v.detailTop > 0 { | |
| 160 | + | v.detailTop-- | |
| 161 | + | } | |
| 162 | + | case tui.KeyRight: | |
| 163 | + | v.detailTop++ | |
| 164 | + | case tui.KeyHome: | |
| 165 | + | v.detailTop = 0 | |
| 166 | + | case tui.KeyEnd: | |
| 167 | + | v.detailTop = int(^uint(0) >> 1) | |
| 168 | + | case tui.KeyUp: | |
| 169 | + | if v.cursor > 0 { | |
| 170 | + | v.cursor-- | |
| 171 | + | } | |
| 172 | + | case tui.KeyDown: | |
| 173 | + | if v.cursor < len(entries)-1 { | |
| 174 | + | v.cursor++ | |
| 175 | + | } | |
| 176 | + | case tui.KeyPageUp: | |
| 177 | + | v.cursor -= 8 | |
| 178 | + | if v.cursor < 0 { | |
| 179 | + | v.cursor = 0 | |
| 180 | + | } | |
| 181 | + | case tui.KeyPageDown: | |
| 182 | + | v.cursor += 8 | |
| 183 | + | clampTimelineCursor(v, len(entries)) | |
| 184 | + | case tui.KeyTab: | |
| 185 | + | v.tab = (v.tab + 1) % timelineTabCount | |
| 186 | + | case tui.KeyShiftTab: | |
| 187 | + | v.tab = (v.tab + timelineTabCount - 1) % timelineTabCount | |
| 188 | + | case tui.KeyCtrlE: | |
| 189 | + | return timelineAction{Export: true} | |
| 190 | + | case tui.KeyBackspace: | |
| 191 | + | if v.searching && v.filter != "" { | |
| 192 | + | r := []rune(v.filter) | |
| 193 | + | v.filter = string(r[:len(r)-1]) | |
| 194 | + | v.cursor = 0 | |
| 195 | + | v.detailTop = 0 | |
| 196 | + | } | |
| 197 | + | case tui.KeyPaste: | |
| 198 | + | if v.searching { | |
| 199 | + | v.filter += sanitizeTimelineLine(singleLinePaste(k.Paste)) | |
| 200 | + | v.cursor = 0 | |
| 201 | + | v.detailTop = 0 | |
| 202 | + | } | |
| 203 | + | case tui.KeyRune: | |
| 204 | + | if !v.searching && k.Rune == '/' { | |
| 205 | + | v.searching = true | |
| 206 | + | v.notice = "" | |
| 207 | + | } else if v.searching && !k.Ctrl && !k.Alt && !k.Super && !unicode.IsControl(k.Rune) { | |
| 208 | + | v.filter += string(k.Rune) | |
| 209 | + | v.cursor = 0 | |
| 210 | + | v.detailTop = 0 | |
| 211 | + | } | |
| 212 | + | case tui.KeyCtrlC: | |
| 213 | + | // Ctrl+C always leaves the view, like every other floating dialog. | |
| 214 | + | v.Close() | |
| 215 | + | return timelineAction{Close: true} | |
| 216 | + | case tui.KeyEsc: | |
| 217 | + | if v.searching { | |
| 218 | + | if v.filter != "" { | |
| 219 | + | v.filter = "" | |
| 220 | + | v.cursor = 0 | |
| 221 | + | v.detailTop = 0 | |
| 222 | + | } else { | |
| 223 | + | v.searching = false | |
| 224 | + | } | |
| 225 | + | break | |
| 226 | + | } | |
| 227 | + | v.Close() | |
| 228 | + | return timelineAction{Close: true} | |
| 229 | + | } | |
| 230 | + | if v.cursor != previousCursor || v.tab != previousTab { | |
| 231 | + | v.detailTop = 0 | |
| 232 | + | } | |
| 233 | + | return timelineAction{} | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func clampTimelineCursor(v *timelineView, n int) { | |
| 237 | + | if n == 0 { | |
| 238 | + | v.cursor = 0 | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | if v.cursor >= n { | |
| 242 | + | v.cursor = n - 1 | |
| 243 | + | } | |
| 244 | + | if v.cursor < 0 { | |
| 245 | + | v.cursor = 0 | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // Render draws the inspector for a pane content area of width x height. The | |
| 250 | + | // first line is a frame header and the last a frame rule so the floating pane | |
| 251 | + | // can lift the header into its border like other dialogs. After padDialogFrame | |
| 252 | + | // and floatingDialogBody, the body never exceeds height rows: optional rows | |
| 253 | + | // (hint, context, tabs, notice) are dropped before the list or detail shrink, | |
| 254 | + | // down to a single list row. | |
| 255 | + | func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData) []string { | |
| 256 | + | if !v.Active() { | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | if width < 1 { | |
| 260 | + | width = 1 | |
| 261 | + | } | |
| 262 | + | entries := v.entries(data) | |
| 263 | + | clampTimelineCursor(v, len(entries)) | |
| 264 | + | ||
| 265 | + | // padDialogFrame may add one blank row after the header and one before | |
| 266 | + | // the rule; both stay in the pane body. | |
| 267 | + | avail := max(0, height-2) | |
| 268 | + | noticeRows := 0 | |
| 269 | + | if v.notice != "" && avail >= 4 { | |
| 270 | + | noticeRows = 1 | |
| 271 | + | } | |
| 272 | + | avail -= noticeRows | |
| 273 | + | tabRows, hintRows, contextRows := 0, 0, 0 | |
| 274 | + | if avail >= 3 { | |
| 275 | + | tabRows = 1 | |
| 276 | + | extra := avail - 3 | |
| 277 | + | hintRows = min(1, extra) | |
| 278 | + | extra -= hintRows | |
| 279 | + | contextRows = min(2, extra) | |
| 280 | + | } | |
| 281 | + | rest := avail - tabRows - hintRows - contextRows | |
| 282 | + | listRows := min(12, max(min(rest, 1), rest/3)) | |
| 283 | + | detailRows := max(0, rest-listRows) | |
| 284 | + | ||
| 285 | + | lines := []string{frameHeader(th, "timeline", width)} | |
| 286 | + | if contextRows > 0 { | |
| 287 | + | lines = append(lines, renderTimelineContext(th, width, data)[:contextRows]...) | |
| 288 | + | } | |
| 289 | + | if hintRows > 0 { | |
| 290 | + | hint := "↑/↓ select tab details ←/→ scroll detail home/end / search ctrl+e export esc close" | |
| 291 | + | if v.searching { | |
| 292 | + | hint = "search: " + sanitizeTimelineLine(v.filter) + "_ (esc clears)" | |
| 293 | + | } | |
| 294 | + | lines = append(lines, th.FGColor(th.Muted, fitTimelineLine(hint, width))) | |
| 295 | + | } | |
| 296 | + | if listRows > 0 { | |
| 297 | + | if len(entries) == 0 { | |
| 298 | + | lines = append(lines, th.FGColor(th.Muted, fitTimelineLine(" no matching events", width))) | |
| 299 | + | } else { | |
| 300 | + | start := v.cursor - listRows/2 | |
| 301 | + | if start < 0 { | |
| 302 | + | start = 0 | |
| 303 | + | } | |
| 304 | + | end := start + listRows | |
| 305 | + | if end > len(entries) { | |
| 306 | + | end = len(entries) | |
| 307 | + | start = max(0, end-listRows) | |
| 308 | + | } | |
| 309 | + | for idx := start; idx < end; idx++ { | |
| 310 | + | line := formatTimelineRow(entries[idx], width) | |
| 311 | + | if idx == v.cursor { | |
| 312 | + | lines = append(lines, th.PadHighlight(line, width)) | |
| 313 | + | } else { | |
| 314 | + | lines = append(lines, timelineColor(th, entries[idx], line)) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | if tabRows > 0 { | |
| 320 | + | lines = append(lines, renderTimelineTabs(th, width, v.tab)) | |
| 321 | + | } | |
| 322 | + | if detailRows > 0 && len(entries) > 0 { | |
| 323 | + | detail := timelineDetailRows(th, width, entries[v.cursor], v.tab) | |
| 324 | + | maxTop := max(0, len(detail)-detailRows) | |
| 325 | + | if v.detailTop > maxTop { | |
| 326 | + | v.detailTop = maxTop | |
| 327 | + | } | |
| 328 | + | lines = append(lines, detail[v.detailTop:min(v.detailTop+detailRows, len(detail))]...) | |
| 329 | + | } | |
| 330 | + | if noticeRows > 0 { | |
| 331 | + | lines = append(lines, th.FGColor(th.Tool, fitTimelineLine(sanitizeTimelineLine(v.notice), width))) | |
| 332 | + | } | |
| 333 | + | lines = append(lines, frameRule(th, width)) | |
| 334 | + | return lines | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // sanitizeTimelineLine makes untrusted transcript text one safe terminal row: | |
| 338 | + | // escape sequences (CSI, OSC), control characters, tabs and CR/LF are removed | |
| 339 | + | // or replaced before the text is measured, truncated or wrapped. | |
| 340 | + | func sanitizeTimelineLine(s string) string { | |
| 341 | + | return sanitizeSessionTreeText(s) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // sanitizeTimelineBlock sanitizes multi-line text row by row, keeping line | |
| 345 | + | // breaks (LF, CRLF and lone CR) and each row's leading indentation. | |
| 346 | + | func sanitizeTimelineBlock(s string) string { | |
| 347 | + | s = strings.ReplaceAll(s, "\r\n", "\n") | |
| 348 | + | s = strings.ReplaceAll(s, "\r", "\n") | |
| 349 | + | rows := strings.Split(s, "\n") | |
| 350 | + | for n, row := range rows { | |
| 351 | + | indent := 0 | |
| 352 | + | indentation: | |
| 353 | + | for _, r := range row { | |
| 354 | + | switch r { | |
| 355 | + | case ' ': | |
| 356 | + | indent++ | |
| 357 | + | case '\t': | |
| 358 | + | indent += 4 | |
| 359 | + | default: | |
| 360 | + | break indentation | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | rows[n] = strings.Repeat(" ", indent) + sanitizeSessionTreeText(row) | |
| 364 | + | } | |
| 365 | + | return strings.Join(rows, "\n") | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // buildTimelineEntries turns a transcript snapshot into ordered rows. Each | |
| 369 | + | // tool call is paired with the next unclaimed result carrying its ID, so an ID | |
| 370 | + | // a provider reuses across turns keeps every call attached to its own result. | |
| 371 | + | func buildTimelineEntries(data timelineData) []timelineEntry { | |
| 372 | + | toolSchemas := make(map[string]string, len(data.Tools)) | |
| 373 | + | for _, tool := range data.Tools { | |
| 374 | + | toolSchemas[tool.Name] = prettyTimelineJSON(tool.Schema) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | entries := make([]timelineEntry, 0, len(data.Messages)+1) | |
| 378 | + | if strings.TrimSpace(data.System) != "" { | |
| 379 | + | entries = append(entries, timelineEntry{ | |
| 380 | + | Kind: "system", Label: "SYSTEM", Summary: firstTimelineLine(data.System), Payload: data.System, | |
| 381 | + | }) | |
| 382 | + | } | |
| 383 | + | // pending maps a call ID to the FIFO of entry indexes still awaiting a result. | |
| 384 | + | pending := map[string][]int{} | |
| 385 | + | callTimes := map[int]time.Time{} | |
| 386 | + | turn := 0 | |
| 387 | + | var previousTime time.Time | |
| 388 | + | for _, message := range data.Messages { | |
| 389 | + | if message.Role == provider.RoleUser { | |
| 390 | + | turn++ | |
| 391 | + | } | |
| 392 | + | for _, content := range message.Content { | |
| 393 | + | if block, ok := content.(provider.ReasoningBlock); ok && strings.TrimSpace(block.Summary) != "" { | |
| 394 | + | entries = append(entries, timelineEntry{ | |
| 395 | + | Kind: "reasoning", Turn: turn, Label: "REASONING", Summary: firstTimelineLine(block.Summary), | |
| 396 | + | Time: message.Time, Payload: block.Summary, | |
| 397 | + | }) | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | textParts := timelineMessageText(message) | |
| 401 | + | if len(textParts) > 0 { | |
| 402 | + | entry := timelineEntry{ | |
| 403 | + | Kind: string(message.Role), Turn: turn, Label: strings.ToUpper(string(message.Role)), | |
| 404 | + | Summary: firstTimelineLine(strings.Join(textParts, "\n")), Time: message.Time, | |
| 405 | + | Payload: prettyTimelineValue(timelineMessagePayload(message)), | |
| 406 | + | } | |
| 407 | + | if !previousTime.IsZero() && !message.Time.IsZero() && message.Time.After(previousTime) { | |
| 408 | + | entry.Duration = message.Time.Sub(previousTime).Round(time.Millisecond).String() | |
| 409 | + | } | |
| 410 | + | entries = append(entries, entry) | |
| 411 | + | if !message.Time.IsZero() { | |
| 412 | + | previousTime = message.Time | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | for _, content := range message.Content { | |
| 416 | + | switch block := content.(type) { | |
| 417 | + | case provider.ToolCallBlock: | |
| 418 | + | entries = append(entries, timelineEntry{ | |
| 419 | + | Kind: "tool", Turn: turn, Label: "TOOL " + block.Name, Summary: summarizeToolArguments(block.Arguments), | |
| 420 | + | Time: message.Time, Payload: prettyTimelineJSON(block.Arguments), Schema: toolSchemas[block.Name], ToolCall: block.ID, | |
| 421 | + | }) | |
| 422 | + | idx := len(entries) - 1 | |
| 423 | + | pending[block.ID] = append(pending[block.ID], idx) | |
| 424 | + | callTimes[idx] = message.Time | |
| 425 | + | case provider.ToolResultBlock: | |
| 426 | + | queue := pending[block.CallID] | |
| 427 | + | result := prettyTimelineValue(timelineResultPayload(block)) | |
| 428 | + | if len(queue) == 0 { | |
| 429 | + | // A result with no open call (for example after a | |
| 430 | + | // compaction boundary) stays visible, marked unpaired. | |
| 431 | + | entries = append(entries, timelineEntry{ | |
| 432 | + | Kind: "tool", Turn: turn, Label: "TOOL RESULT", Summary: "(unpaired result)", | |
| 433 | + | Time: message.Time, Result: result, IsError: block.IsError, ToolCall: block.CallID, | |
| 434 | + | }) | |
| 435 | + | continue | |
| 436 | + | } | |
| 437 | + | idx := queue[0] | |
| 438 | + | pending[block.CallID] = queue[1:] | |
| 439 | + | entries[idx].Result = result | |
| 440 | + | entries[idx].IsError = block.IsError | |
| 441 | + | if started := callTimes[idx]; !started.IsZero() && message.Time.After(started) { | |
| 442 | + | entries[idx].Duration = message.Time.Sub(started).Round(time.Millisecond).String() | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | return entries | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func timelineMessageText(message provider.Message) []string { | |
| 451 | + | var out []string | |
| 452 | + | for _, content := range message.Content { | |
| 453 | + | switch block := content.(type) { | |
| 454 | + | case provider.TextBlock: | |
| 455 | + | if strings.TrimSpace(block.Text) != "" { | |
| 456 | + | out = append(out, block.Text) | |
| 457 | + | } | |
| 458 | + | case provider.ImageBlock: | |
| 459 | + | out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data))) | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | return out | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | // timelineMessagePayload describes a message without provider-private data: | |
| 466 | + | // reasoning shows only its readable summary (never the encrypted content), text | |
| 467 | + | // omits thought signatures, and images show only type and size. | |
| 468 | + | func timelineMessagePayload(message provider.Message) any { | |
| 469 | + | blocks := make([]any, 0, len(message.Content)) | |
| 470 | + | for _, content := range message.Content { | |
| 471 | + | switch block := content.(type) { | |
| 472 | + | case provider.TextBlock: | |
| 473 | + | blocks = append(blocks, map[string]any{"type": "text", "text": block.Text}) | |
| 474 | + | case provider.ReasoningBlock: | |
| 475 | + | blocks = append(blocks, map[string]any{"type": "reasoning", "summary": block.Summary}) | |
| 476 | + | case provider.ImageBlock: | |
| 477 | + | blocks = append(blocks, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)}) | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | return map[string]any{"role": message.Role, "content": blocks, "meta": message.Meta} | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func timelineResultPayload(result provider.ToolResultBlock) any { | |
| 484 | + | parts := make([]any, 0, len(result.Content)) | |
| 485 | + | for _, content := range result.Content { | |
| 486 | + | switch block := content.(type) { | |
| 487 | + | case provider.TextBlock: | |
| 488 | + | parts = append(parts, map[string]any{"type": "text", "text": block.Text}) | |
| 489 | + | case provider.ImageBlock: | |
| 490 | + | parts = append(parts, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)}) | |
| 491 | + | default: | |
| 492 | + | parts = append(parts, fmt.Sprintf("%T", content)) | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | return map[string]any{"content": parts, "is_error": result.IsError} | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | func filterTimelineEntries(entries []timelineEntry, filter string) []timelineEntry { | |
| 499 | + | filter = strings.ToLower(strings.TrimSpace(filter)) | |
| 500 | + | if filter == "" { | |
| 501 | + | return entries | |
| 502 | + | } | |
| 503 | + | out := make([]timelineEntry, 0, len(entries)) | |
| 504 | + | for _, entry := range entries { | |
| 505 | + | haystack := strings.ToLower(strings.Join([]string{entry.Label, entry.Summary, entry.Payload, entry.Result}, "\n")) | |
| 506 | + | if strings.Contains(haystack, filter) { | |
| 507 | + | out = append(out, entry) | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | return out | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | func renderTimelineContext(th tui.Theme, width int, data timelineData) []string { | |
| 514 | + | est := data.Est | |
| 515 | + | if !est.ok { | |
| 516 | + | est = computeTimelineEstimates(data.System, data.Tools, data.Messages) | |
| 517 | + | } | |
| 518 | + | system, tools, messages := est.system, est.tools, est.messages | |
| 519 | + | ||
| 520 | + | usage := fmt.Sprintf("context %s", compactTimelineNumber(data.ContextUsed)) | |
| 521 | + | if data.ContextMax > 0 { | |
| 522 | + | pct := float64(data.ContextUsed) * 100 / float64(data.ContextMax) | |
| 523 | + | usage = fmt.Sprintf("context %s / %s (%.1f%%)", compactTimelineNumber(data.ContextUsed), compactTimelineNumber(data.ContextMax), pct) | |
| 524 | + | } | |
| 525 | + | composition := fmt.Sprintf("estimated composition: system %s tools %s messages %s", compactTimelineNumber(system), compactTimelineNumber(tools), compactTimelineNumber(messages)) | |
| 526 | + | return []string{tui.Bold(fitTimelineLine(usage, width)), th.FGColor(th.Muted, fitTimelineLine(composition, width))} | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func computeTimelineEstimates(system string, tools []provider.Tool, messages []provider.Message) timelineEstimates { | |
| 530 | + | toolBytes, _ := json.Marshal(tools) | |
| 531 | + | return timelineEstimates{ | |
| 532 | + | system: estimateTimelineTokens(system), | |
| 533 | + | tools: estimateTimelineTokens(string(toolBytes)), | |
| 534 | + | messages: estimateTimelineMessageTokens(messages), | |
| 535 | + | ok: true, | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | func formatTimelineRow(entry timelineEntry, width int) string { | |
| 540 | + | stamp := " " | |
| 541 | + | if !entry.Time.IsZero() { | |
| 542 | + | stamp = entry.Time.Format("15:04:05") | |
| 543 | + | } | |
| 544 | + | turn := " " | |
| 545 | + | if entry.Turn > 0 { | |
| 546 | + | turn = fmt.Sprintf("%3d", entry.Turn) | |
| 547 | + | } | |
| 548 | + | line := fmt.Sprintf("%s %s %-16s %s", stamp, turn, sanitizeTimelineLine(entry.Label), sanitizeTimelineLine(entry.Summary)) | |
| 549 | + | return fitTimelineLine(line, width) | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | func timelineColor(th tui.Theme, entry timelineEntry, line string) string { | |
| 553 | + | color := th.Muted | |
| 554 | + | switch entry.Kind { | |
| 555 | + | case "user": | |
| 556 | + | color = th.User | |
| 557 | + | case "assistant", "reasoning": | |
| 558 | + | color = th.Assistant | |
| 559 | + | case "tool": | |
| 560 | + | color = th.Tool | |
| 561 | + | case "system": | |
| 562 | + | color = th.Warning | |
| 563 | + | } | |
| 564 | + | if entry.IsError { | |
| 565 | + | color = th.Error | |
| 566 | + | } | |
| 567 | + | return th.FGColor(color, line) | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | func renderTimelineTabs(th tui.Theme, width, selected int) string { | |
| 571 | + | if selected < 0 || selected >= len(timelineTabNames) || width < 1 { | |
| 572 | + | return "" | |
| 573 | + | } | |
| 574 | + | start, end := selected, selected+1 | |
| 575 | + | for start > 0 || end < len(timelineTabNames) { | |
| 576 | + | grew := false | |
| 577 | + | if start > 0 && timelineTabsWidth(start-1, end) <= width { | |
| 578 | + | start-- | |
| 579 | + | grew = true | |
| 580 | + | } | |
| 581 | + | if end < len(timelineTabNames) && timelineTabsWidth(start, end+1) <= width { | |
| 582 | + | end++ | |
| 583 | + | grew = true | |
| 584 | + | } | |
| 585 | + | if !grew { | |
| 586 | + | break | |
| 587 | + | } | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | tabs := make([]string, 0, end-start) | |
| 591 | + | for idx := start; idx < end; idx++ { | |
| 592 | + | name := timelineTabNames[idx] | |
| 593 | + | if idx == selected { | |
| 594 | + | tabs = append(tabs, tui.Bold(th.FGColor(th.Accent, "["+name+"]"))) | |
| 595 | + | } else { | |
| 596 | + | tabs = append(tabs, th.FGColor(th.Muted, " "+name+" ")) | |
| 597 | + | } | |
| 598 | + | } | |
| 599 | + | return strings.Join(tabs, " ") | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | func timelineTabsWidth(start, end int) int { | |
| 603 | + | width := 2 * (end - start - 1) | |
| 604 | + | for idx := start; idx < end; idx++ { | |
| 605 | + | width += runewidth.StringWidth(timelineTabNames[idx]) + 2 | |
| 606 | + | } | |
| 607 | + | return width | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | func timelineDetailRows(th tui.Theme, width int, entry timelineEntry, tab int) []string { | |
| 611 | + | var detail string | |
| 612 | + | switch tab { | |
| 613 | + | case 0: | |
| 614 | + | detail = fmt.Sprintf("type: %s\nturn: %d\nstatus: %s\nsummary: %s", entry.Kind, entry.Turn, timelineStatus(entry), entry.Summary) | |
| 615 | + | case 1: | |
| 616 | + | detail = entry.Payload | |
| 617 | + | case 2: | |
| 618 | + | detail = entry.Result | |
| 619 | + | case 3: | |
| 620 | + | detail = entry.Schema | |
| 621 | + | case 4: | |
| 622 | + | started := "not recorded" | |
| 623 | + | if !entry.Time.IsZero() { | |
| 624 | + | started = entry.Time.Format(time.RFC3339Nano) | |
| 625 | + | } | |
| 626 | + | duration := entry.Duration | |
| 627 | + | if duration == "" { | |
| 628 | + | duration = "not recorded" | |
| 629 | + | } | |
| 630 | + | detail = "started: " + started + "\nduration: " + duration + "\nsource: transcript timestamps" | |
| 631 | + | } | |
| 632 | + | detail = sanitizeTimelineBlock(detail) | |
| 633 | + | if strings.TrimSpace(detail) == "" { | |
| 634 | + | detail = "not available for this event" | |
| 635 | + | } | |
| 636 | + | if entry.Kind == "reasoning" && (tab == 0 || tab == 1) { | |
| 637 | + | return renderDialogMarkdownRows(sanitizeTimelineBlock(entry.Payload), th, width) | |
| 638 | + | } | |
| 639 | + | var rows []string | |
| 640 | + | for _, line := range strings.Split(detail, "\n") { | |
| 641 | + | for _, wrapped := range tui.WrapANSILine(line, max(1, width-2)) { | |
| 642 | + | rows = append(rows, " "+th.FGColor(th.ToolOut, wrapped)) | |
| 643 | + | } | |
| 644 | + | } | |
| 645 | + | return rows | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | func timelineStatus(entry timelineEntry) string { | |
| 649 | + | if entry.IsError { | |
| 650 | + | return "error" | |
| 651 | + | } | |
| 652 | + | if entry.Kind == "tool" && entry.Result == "" { | |
| 653 | + | return "result unavailable" | |
| 654 | + | } | |
| 655 | + | return "completed" | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | // Export writes the timeline as JSON into dir (the default export directory | |
| 659 | + | // when empty). The file is created exclusively with owner-only permissions so | |
| 660 | + | // an existing file or symlink is never overwritten or widened. | |
| 661 | + | func (v *timelineView) Export(dir string, data timelineData) (string, error) { | |
| 662 | + | if dir == "" { | |
| 663 | + | dir = defaultExportDir() | |
| 664 | + | } | |
| 665 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 666 | + | return "", fmt.Errorf("create export directory: %w", err) | |
| 667 | + | } | |
| 668 | + | payload := struct { | |
| 669 | + | ExportedAt time.Time `json:"exported_at"` | |
| 670 | + | ContextUsed int `json:"context_used"` | |
| 671 | + | ContextMax int `json:"context_max"` | |
| 672 | + | Events []timelineEntry `json:"events"` | |
| 673 | + | }{time.Now(), data.ContextUsed, data.ContextMax, buildTimelineEntries(data)} | |
| 674 | + | encoded, err := json.MarshalIndent(payload, "", " ") | |
| 675 | + | if err != nil { | |
| 676 | + | return "", fmt.Errorf("encode timeline: %w", err) | |
| 677 | + | } | |
| 678 | + | name := "zut-timeline-" + time.Now().Format("20060102-150405.000000000") + ".json" | |
| 679 | + | path := filepath.Join(dir, name) | |
| 680 | + | file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | |
| 681 | + | if err != nil { | |
| 682 | + | return "", fmt.Errorf("write timeline: %w", err) | |
| 683 | + | } | |
| 684 | + | if _, err := file.Write(append(encoded, '\n')); err != nil { | |
| 685 | + | _ = file.Close() | |
| 686 | + | _ = os.Remove(path) | |
| 687 | + | return "", fmt.Errorf("write timeline: %w", err) | |
| 688 | + | } | |
| 689 | + | if err := file.Close(); err != nil { | |
| 690 | + | _ = os.Remove(path) | |
| 691 | + | return "", fmt.Errorf("write timeline: %w", err) | |
| 692 | + | } | |
| 693 | + | return path, nil | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | func firstTimelineLine(s string) string { | |
| 697 | + | for _, line := range strings.Split(s, "\n") { | |
| 698 | + | if line = strings.TrimSpace(line); line != "" { | |
| 699 | + | return fitTimelineLine(line, 120) | |
| 700 | + | } | |
| 701 | + | } | |
| 702 | + | return "(empty)" | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | func summarizeToolArguments(raw json.RawMessage) string { | |
| 706 | + | var object map[string]any | |
| 707 | + | if json.Unmarshal(raw, &object) == nil { | |
| 708 | + | for _, key := range []string{"command", "path", "query", "pattern"} { | |
| 709 | + | if value, ok := object[key]; ok { | |
| 710 | + | return firstTimelineLine(fmt.Sprint(value)) | |
| 711 | + | } | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | return firstTimelineLine(string(raw)) | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | func prettyTimelineJSON(raw json.RawMessage) string { | |
| 718 | + | if len(raw) == 0 { | |
| 719 | + | return "" | |
| 720 | + | } | |
| 721 | + | var value any | |
| 722 | + | if json.Unmarshal(raw, &value) != nil { | |
| 723 | + | return string(raw) | |
| 724 | + | } | |
| 725 | + | return prettyTimelineValue(value) | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | func prettyTimelineValue(value any) string { | |
| 729 | + | encoded, err := json.MarshalIndent(value, "", " ") | |
| 730 | + | if err != nil { | |
| 731 | + | return fmt.Sprint(value) | |
| 732 | + | } | |
| 733 | + | return string(encoded) | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | func estimateTimelineTokens(s string) int { | |
| 737 | + | if s == "" { | |
| 738 | + | return 0 | |
| 739 | + | } | |
| 740 | + | return (len([]byte(s)) + 3) / 4 | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | func estimateTimelineMessageTokens(messages []provider.Message) int { | |
| 744 | + | bytes := 0 | |
| 745 | + | for _, message := range messages { | |
| 746 | + | bytes += len(message.Role) + 12 | |
| 747 | + | for _, content := range message.Content { | |
| 748 | + | switch block := content.(type) { | |
| 749 | + | case provider.TextBlock: | |
| 750 | + | bytes += len(block.Text) + len(block.ThoughtSignature) | |
| 751 | + | case provider.ReasoningBlock: | |
| 752 | + | // Sizes only: the encrypted payload is counted toward the | |
| 753 | + | // estimate but never displayed. | |
| 754 | + | bytes += len(block.Summary) + len(block.Encrypted) | |
| 755 | + | case provider.ImageBlock: | |
| 756 | + | // JSON transports image bytes as base64, which expands by roughly 4/3. | |
| 757 | + | bytes += (len(block.Data)*4 + 2) / 3 | |
| 758 | + | case provider.ToolCallBlock: | |
| 759 | + | bytes += len(block.ID) + len(block.Name) + len(block.Arguments) | |
| 760 | + | case provider.ToolResultBlock: | |
| 761 | + | bytes += len(block.CallID) + estimateTimelineResultBytes(block.Content) | |
| 762 | + | } | |
| 763 | + | } | |
| 764 | + | } | |
| 765 | + | return (bytes + 3) / 4 | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | func estimateTimelineResultBytes(contents []provider.Content) int { | |
| 769 | + | bytes := 0 | |
| 770 | + | for _, content := range contents { | |
| 771 | + | switch block := content.(type) { | |
| 772 | + | case provider.TextBlock: | |
| 773 | + | bytes += len(block.Text) | |
| 774 | + | case provider.ImageBlock: | |
| 775 | + | bytes += (len(block.Data)*4 + 2) / 3 | |
| 776 | + | } | |
| 777 | + | } | |
| 778 | + | return bytes | |
| 779 | + | } | |
| 780 | + | ||
| 781 | + | func compactTimelineNumber(n int) string { | |
| 782 | + | if n >= 1_000_000 { | |
| 783 | + | return fmt.Sprintf("%.1fm", float64(n)/1_000_000) | |
| 784 | + | } | |
| 785 | + | if n >= 1_000 { | |
| 786 | + | return fmt.Sprintf("%.1fk", float64(n)/1_000) | |
| 787 | + | } | |
| 788 | + | return fmt.Sprintf("%d", n) | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | func fitTimelineLine(s string, width int) string { | |
| 792 | + | if width < 1 { | |
| 793 | + | return "" | |
| 794 | + | } | |
| 795 | + | if runewidth.StringWidth(s) <= width { | |
| 796 | + | return s | |
| 797 | + | } | |
| 798 | + | return runewidth.Truncate(s, width, "...") | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | // timelineDataLocked returns the timeline snapshot for the live agent using | |
| 802 | + | // its locked accessors. Host-internal continuation prompts are dropped exactly | |
| 803 | + | // as the chat transcript drops them. The transcript and tool specs are only | |
| 804 | + | // copied when the transcript revision or the system/tool content hash changed; | |
| 805 | + | // otherwise the cached snapshot (and its token estimates) is reused. The | |
| 806 | + | // caller must hold i.mu. | |
| 807 | + | func (i *Interactive) timelineDataLocked() timelineData { | |
| 808 | + | if i.timeline == nil { | |
| 809 | + | i.timeline = newTimelineView() | |
| 810 | + | } | |
| 811 | + | v := i.timeline | |
| 812 | + | var data timelineData | |
| 813 | + | if i.agent != nil { | |
| 814 | + | system, registry := i.agent.PromptConfig() | |
| 815 | + | specs := registry.Specs() | |
| 816 | + | // Read the revision before the messages: a concurrent append can only | |
| 817 | + | // make the cached snapshot look stale, never newer than it is. | |
| 818 | + | key := timelineCacheKey{agent: i.agent, rev: i.agent.Revision(), prompt: timelinePromptHash(system, specs)} | |
| 819 | + | if v.snapValid && v.snapKey == key { | |
| 820 | + | data = v.snap | |
| 821 | + | } else { | |
| 822 | + | data = timelineData{System: system, Tools: specs, Key: key} | |
| 823 | + | data.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 824 | + | data.Est = computeTimelineEstimates(system, specs, data.Messages) | |
| 825 | + | v.snapKey, v.snap, v.snapValid = key, data, true | |
| 826 | + | } | |
| 827 | + | } | |
| 828 | + | data.ContextUsed = i.lastCtxInput | |
| 829 | + | if model, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | |
| 830 | + | data.ContextMax = model.ContextWindow | |
| 831 | + | } | |
| 832 | + | return data | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | // timelinePromptHash fingerprints the system prompt and tool specs by content, | |
| 836 | + | // so a same-length edit to either still invalidates the cached snapshot. | |
| 837 | + | func timelinePromptHash(system string, tools []provider.Tool) uint64 { | |
| 838 | + | h := fnv.New64a() | |
| 839 | + | write := func(s string) { | |
| 840 | + | _, _ = h.Write([]byte(s)) | |
| 841 | + | _, _ = h.Write([]byte{0}) | |
| 842 | + | } | |
| 843 | + | write(system) | |
| 844 | + | for _, tool := range tools { | |
| 845 | + | write(tool.Name) | |
| 846 | + | write(tool.Description) | |
| 847 | + | _, _ = h.Write(tool.Schema) | |
| 848 | + | _, _ = h.Write([]byte{0}) | |
| 849 | + | if tool.Deferred { | |
| 850 | + | write("deferred") | |
| 851 | + | } | |
| 852 | + | } | |
| 853 | + | return h.Sum64() | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | // openTimeline shows the inspector in the floating pane. | |
| 857 | + | func (i *Interactive) openTimeline() { | |
| 858 | + | i.mu.Lock() | |
| 859 | + | if i.timeline == nil { | |
| 860 | + | i.timeline = newTimelineView() | |
| 861 | + | } | |
| 862 | + | i.timeline.Open(i.timelineDataLocked()) | |
| 863 | + | i.statusErr = "" | |
| 864 | + | i.statusOK = "" | |
| 865 | + | i.mu.Unlock() | |
| 866 | + | i.invalidate() | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | // handleTimelineKey routes one key to the open timeline. View state is shared | |
| 870 | + | // with the renderer, so it is only touched under i.mu; the export's file I/O | |
| 871 | + | // runs outside the lock on the snapshot taken with the key. | |
| 872 | + | func (i *Interactive) handleTimelineKey(k tui.Key) { | |
| 873 | + | i.mu.Lock() | |
| 874 | + | data := i.timelineDataLocked() | |
| 875 | + | act := i.timeline.HandleKey(k, data) | |
| 876 | + | i.mu.Unlock() | |
| 877 | + | if act.Export { | |
| 878 | + | path, err := i.timeline.Export("", data) | |
| 879 | + | i.mu.Lock() | |
| 880 | + | if err != nil { | |
| 881 | + | i.timeline.SetNotice("export failed: " + err.Error()) | |
| 882 | + | } else { | |
| 883 | + | i.timeline.SetNotice("exported to " + friendlyPath(path)) | |
| 884 | + | } | |
| 885 | + | i.mu.Unlock() | |
| 886 | + | } | |
| 887 | + | i.invalidate() | |
| 888 | + | } | |
A packages/agent/modes/timeline_view_test.go +576 -0
| @@ -0,0 +1,576 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/mattn/go-runewidth" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/zut/packages/core" | |
| 16 | + | "github.com/bnema/zut/packages/provider" | |
| 17 | + | "github.com/bnema/zut/packages/tui" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func timelineTestData() timelineData { | |
| 21 | + | started := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) | |
| 22 | + | finished := started.Add(1250 * time.Millisecond) | |
| 23 | + | return timelineData{ | |
| 24 | + | System: "You are zot.", | |
| 25 | + | Tools: []provider.Tool{{ | |
| 26 | + | Name: "bash", Schema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`), | |
| 27 | + | }}, | |
| 28 | + | Messages: []provider.Message{ | |
| 29 | + | {Role: provider.RoleUser, Time: started.Add(-time.Second), Content: []provider.Content{provider.TextBlock{Text: "list files"}}}, | |
| 30 | + | {Role: provider.RoleAssistant, Time: started, Content: []provider.Content{ | |
| 31 | + | provider.TextBlock{Text: "I will inspect them."}, | |
| 32 | + | provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls -la"}`)}, | |
| 33 | + | }}, | |
| 34 | + | {Role: provider.RoleTool, Time: finished, Content: []provider.Content{ | |
| 35 | + | provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "README.md"}}}, | |
| 36 | + | }}, | |
| 37 | + | }, | |
| 38 | + | ContextUsed: 1200, | |
| 39 | + | ContextMax: 200000, | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestSessionTimelineOpensAlternateView(t *testing.T) { | |
| 44 | + | interactive := &Interactive{timeline: newTimelineView()} | |
| 45 | + | interactive.runSlash(context.Background(), "/session timeline") | |
| 46 | + | if !interactive.timeline.Active() { | |
| 47 | + | t.Fatal("/session timeline did not open the timeline view") | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestSessionPickerIncludesTimeline(t *testing.T) { | |
| 52 | + | interactive := &Interactive{sessionOpsDialog: newSessionOpsDialog()} | |
| 53 | + | interactive.openSessionOpsDialog() | |
| 54 | + | if len(interactive.sessionOpsDialog.items) == 0 || interactive.sessionOpsDialog.items[0].action != "timeline" { | |
| 55 | + | t.Fatalf("session actions = %+v, want timeline first", interactive.sessionOpsDialog.items) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) { | |
| 60 | + | entries := buildTimelineEntries(timelineTestData()) | |
| 61 | + | if len(entries) != 4 { | |
| 62 | + | t.Fatalf("entries = %d, want system, user, assistant, and tool", len(entries)) | |
| 63 | + | } | |
| 64 | + | tool := entries[3] | |
| 65 | + | if tool.Kind != "tool" || tool.ToolCall != "call-1" { | |
| 66 | + | t.Fatalf("tool entry = %+v", tool) | |
| 67 | + | } | |
| 68 | + | if !strings.Contains(tool.Payload, "ls -la") || !strings.Contains(tool.Result, "README.md") { | |
| 69 | + | t.Fatalf("tool details missing: %+v", tool) | |
| 70 | + | } | |
| 71 | + | if !strings.Contains(tool.Schema, "command") { | |
| 72 | + | t.Fatalf("tool schema missing: %q", tool.Schema) | |
| 73 | + | } | |
| 74 | + | if tool.Duration != "1.25s" { | |
| 75 | + | t.Fatalf("duration = %q, want 1.25s", tool.Duration) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestTimelineReasoningIsReadableAndScrollable(t *testing.T) { | |
| 80 | + | data := timelineTestData() | |
| 81 | + | data.Messages[1].Content = append([]provider.Content{provider.ReasoningBlock{ | |
| 82 | + | Summary: "**First** step\n" + strings.Repeat("A long line of thought. ", 25) + "\nFinal insight", | |
| 83 | + | }}, data.Messages[1].Content...) | |
| 84 | + | entries := buildTimelineEntries(data) | |
| 85 | + | if len(entries) != 5 || entries[2].Kind != "reasoning" || entries[3].Kind != "assistant" { | |
| 86 | + | t.Fatalf("reasoning entries = %+v", entries) | |
| 87 | + | } | |
| 88 | + | if strings.Contains(entries[3].Summary, "First") { | |
| 89 | + | t.Fatalf("reasoning duplicated in assistant: %+v", entries[3]) | |
| 90 | + | } | |
| 91 | + | view := newTimelineView() | |
| 92 | + | view.Open(data) | |
| 93 | + | view.cursor = 2 | |
| 94 | + | rows := timelineDetailRows(tui.Dark, 40, entries[2], 0) | |
| 95 | + | if len(rows) < 4 || !strings.Contains(stripANSIBytes(strings.Join(rows, "\n")), "Final insight") { | |
| 96 | + | t.Fatalf("reasoning detail not complete: %q", rows) | |
| 97 | + | } | |
| 98 | + | for _, row := range rows { | |
| 99 | + | if runewidth.StringWidth(stripANSIBytes(row)) > 40 { | |
| 100 | + | t.Fatalf("row exceeds width: %q", row) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | first := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n") | |
| 104 | + | view.HandleKey(tui.Key{Kind: tui.KeyEnd}, data) | |
| 105 | + | last := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n") | |
| 106 | + | if view.detailTop == 0 || first == last || !strings.Contains(stripANSIBytes(last), "Final insight") { | |
| 107 | + | t.Fatalf("detail did not scroll to end: top=%d, output=%q", view.detailTop, last) | |
| 108 | + | } | |
| 109 | + | view.HandleKey(tui.Key{Kind: tui.KeyLeft}, data) | |
| 110 | + | if view.detailTop == 0 { | |
| 111 | + | t.Fatal("left did not scroll detail up") | |
| 112 | + | } | |
| 113 | + | view.HandleKey(tui.Key{Kind: tui.KeyTab}, data) | |
| 114 | + | if view.detailTop != 0 { | |
| 115 | + | t.Fatal("changing tab did not reset detail scroll") | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestTimelineNavigationKeysMoveSelection(t *testing.T) { | |
| 120 | + | data := timelineTestData() | |
| 121 | + | view := newTimelineView() | |
| 122 | + | view.Open(data) | |
| 123 | + | last := len(buildTimelineEntries(data)) - 1 | |
| 124 | + | if view.cursor != last { | |
| 125 | + | t.Fatalf("initial cursor = %d, want %d", view.cursor, last) | |
| 126 | + | } | |
| 127 | + | view.HandleKey(tui.Key{Kind: tui.KeyUp}, data) | |
| 128 | + | if view.cursor != last-1 { | |
| 129 | + | t.Fatalf("cursor after up = %d, want %d", view.cursor, last-1) | |
| 130 | + | } | |
| 131 | + | view.HandleKey(tui.Key{Kind: tui.KeyDown}, data) | |
| 132 | + | if view.cursor != last { | |
| 133 | + | t.Fatalf("cursor after down = %d, want %d", view.cursor, last) | |
| 134 | + | } | |
| 135 | + | view.HandleKey(tui.Key{Kind: tui.KeyPageUp}, data) | |
| 136 | + | if view.cursor != 0 { | |
| 137 | + | t.Fatalf("cursor after page up = %d, want 0", view.cursor) | |
| 138 | + | } | |
| 139 | + | view.HandleKey(tui.Key{Kind: tui.KeyPageDown}, data) | |
| 140 | + | if view.cursor != last { | |
| 141 | + | t.Fatalf("cursor after page down = %d, want %d", view.cursor, last) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestTimelineSearchAndTabs(t *testing.T) { | |
| 146 | + | data := timelineTestData() | |
| 147 | + | view := newTimelineView() | |
| 148 | + | view.Open(data) | |
| 149 | + | view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data) | |
| 150 | + | for _, r := range "readme" { | |
| 151 | + | view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data) | |
| 152 | + | } | |
| 153 | + | matches := filterTimelineEntries(buildTimelineEntries(data), view.filter) | |
| 154 | + | if len(matches) != 1 || matches[0].Kind != "tool" { | |
| 155 | + | t.Fatalf("matches = %+v, want tool result", matches) | |
| 156 | + | } | |
| 157 | + | view.HandleKey(tui.Key{Kind: tui.KeyTab}, data) | |
| 158 | + | if view.tab != 1 { | |
| 159 | + | t.Fatalf("tab = %d, want payload tab", view.tab) | |
| 160 | + | } | |
| 161 | + | view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data) | |
| 162 | + | if view.filter != "" || !view.Active() { | |
| 163 | + | t.Fatal("first esc should clear search without closing timeline") | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestTimelineTabsKeepSelectedTabVisible(t *testing.T) { | |
| 168 | + | const width = 30 | |
| 169 | + | for selected, name := range timelineTabNames { | |
| 170 | + | rendered := renderTimelineTabs(tui.Theme{}, width, selected) | |
| 171 | + | plain := stripANSIBytes(rendered) | |
| 172 | + | if !strings.Contains(plain, "["+name+"]") { | |
| 173 | + | t.Errorf("selected tab %q not visible in %q", name, plain) | |
| 174 | + | } | |
| 175 | + | if got := runewidth.StringWidth(plain); got > width { | |
| 176 | + | t.Errorf("tabs width = %d, want <= %d: %q", got, width, plain) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestTimelineRenderShowsContextAndControls(t *testing.T) { | |
| 182 | + | data := timelineTestData() | |
| 183 | + | if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 { | |
| 184 | + | t.Fatalf("context rows = %d, want usage and composition only", len(contextLines)) | |
| 185 | + | } | |
| 186 | + | view := newTimelineView() | |
| 187 | + | view.Open(data) | |
| 188 | + | rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n") | |
| 189 | + | for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} { | |
| 190 | + | if !strings.Contains(rendered, want) { | |
| 191 | + | t.Fatalf("render missing %q:\n%s", want, rendered) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) { | |
| 197 | + | data := timelineTestData() | |
| 198 | + | data.Messages = append(data.Messages, provider.Message{ | |
| 199 | + | Role: provider.RoleUser, | |
| 200 | + | Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}}, | |
| 201 | + | }) | |
| 202 | + | view := newTimelineView() | |
| 203 | + | path, err := view.Export(t.TempDir(), data) | |
| 204 | + | if err != nil { | |
| 205 | + | t.Fatal(err) | |
| 206 | + | } | |
| 207 | + | contents, err := os.ReadFile(path) | |
| 208 | + | if err != nil { | |
| 209 | + | t.Fatal(err) | |
| 210 | + | } | |
| 211 | + | if strings.Contains(string(contents), "secret-image-bytes") { | |
| 212 | + | t.Fatal("export embedded image data") | |
| 213 | + | } | |
| 214 | + | if !strings.Contains(string(contents), "image/png") { | |
| 215 | + | t.Fatal("export omitted image metadata") | |
| 216 | + | } | |
| 217 | + | info, err := os.Stat(path) | |
| 218 | + | if err != nil { | |
| 219 | + | t.Fatal(err) | |
| 220 | + | } | |
| 221 | + | if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { | |
| 222 | + | t.Fatalf("permissions = %o, want 600", info.Mode().Perm()) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestTimelinePairsReusedToolCallIDs(t *testing.T) { | |
| 227 | + | t0 := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) | |
| 228 | + | call := func(cmd string) provider.Content { | |
| 229 | + | return provider.ToolCallBlock{ID: "reused", Name: "bash", Arguments: json.RawMessage(`{"command":"` + cmd + `"}`)} | |
| 230 | + | } | |
| 231 | + | result := func(text string, isErr bool) provider.Message { | |
| 232 | + | return provider.Message{Role: provider.RoleTool, Time: t0.Add(time.Second), Content: []provider.Content{ | |
| 233 | + | provider.ToolResultBlock{CallID: "reused", IsError: isErr, Content: []provider.Content{provider.TextBlock{Text: text}}}, | |
| 234 | + | }} | |
| 235 | + | } | |
| 236 | + | data := timelineData{Messages: []provider.Message{ | |
| 237 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "go"}}}, | |
| 238 | + | {Role: provider.RoleAssistant, Time: t0, Content: []provider.Content{call("first")}}, | |
| 239 | + | result("first-output", false), | |
| 240 | + | {Role: provider.RoleAssistant, Time: t0.Add(2 * time.Second), Content: []provider.Content{call("second")}}, | |
| 241 | + | result("second-output", true), | |
| 242 | + | {Role: provider.RoleAssistant, Time: t0.Add(4 * time.Second), Content: []provider.Content{call("third")}}, | |
| 243 | + | }} | |
| 244 | + | var tools []timelineEntry | |
| 245 | + | for _, e := range buildTimelineEntries(data) { | |
| 246 | + | if e.Kind == "tool" { | |
| 247 | + | tools = append(tools, e) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | if len(tools) != 3 { | |
| 251 | + | t.Fatalf("tool entries = %d, want 3: %+v", len(tools), tools) | |
| 252 | + | } | |
| 253 | + | if !strings.Contains(tools[0].Result, "first-output") || tools[0].IsError { | |
| 254 | + | t.Fatalf("first call paired wrongly: %+v", tools[0]) | |
| 255 | + | } | |
| 256 | + | if !strings.Contains(tools[1].Result, "second-output") || !tools[1].IsError { | |
| 257 | + | t.Fatalf("second call paired wrongly: %+v", tools[1]) | |
| 258 | + | } | |
| 259 | + | if tools[2].Result != "" || timelineStatus(tools[2]) != "result unavailable" { | |
| 260 | + | t.Fatalf("third call must stay unpaired: %+v", tools[2]) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestTimelineKeepsOrphanResultVisible(t *testing.T) { | |
| 265 | + | data := timelineData{Messages: []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{ | |
| 266 | + | provider.ToolResultBlock{CallID: "gone", Content: []provider.Content{provider.TextBlock{Text: "late"}}}, | |
| 267 | + | }}}} | |
| 268 | + | entries := buildTimelineEntries(data) | |
| 269 | + | if len(entries) != 1 || !strings.Contains(entries[0].Result, "late") || !strings.Contains(entries[0].Summary, "unpaired") { | |
| 270 | + | t.Fatalf("orphan result = %+v", entries) | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestTimelineNeverShowsOrExportsEncryptedReasoningOrSignatures(t *testing.T) { | |
| 275 | + | data := timelineTestData() | |
| 276 | + | data.Messages[1].Content = append([]provider.Content{ | |
| 277 | + | provider.ReasoningBlock{ID: "r1", Summary: "visible summary", Encrypted: "ENCRYPTED-SECRET-BLOB"}, | |
| 278 | + | provider.TextBlock{Text: "answer", ThoughtSignature: "SIGNATURE-SECRET"}, | |
| 279 | + | }, data.Messages[1].Content...) | |
| 280 | + | view := newTimelineView() | |
| 281 | + | view.Open(data) | |
| 282 | + | var shown strings.Builder | |
| 283 | + | for tab := 0; tab < timelineTabCount; tab++ { | |
| 284 | + | view.tab = tab | |
| 285 | + | for cursor := range buildTimelineEntries(data) { | |
| 286 | + | view.cursor = cursor | |
| 287 | + | shown.WriteString(strings.Join(view.Render(tui.Dark, 100, 40, data), "\n")) | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | path, err := view.Export(t.TempDir(), data) | |
| 291 | + | if err != nil { | |
| 292 | + | t.Fatal(err) | |
| 293 | + | } | |
| 294 | + | exported, err := os.ReadFile(path) | |
| 295 | + | if err != nil { | |
| 296 | + | t.Fatal(err) | |
| 297 | + | } | |
| 298 | + | for _, secret := range []string{"ENCRYPTED-SECRET-BLOB", "SIGNATURE-SECRET"} { | |
| 299 | + | if strings.Contains(shown.String(), secret) || strings.Contains(string(exported), secret) { | |
| 300 | + | t.Fatalf("%s leaked into the timeline", secret) | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | if !strings.Contains(string(exported), "visible summary") { | |
| 304 | + | t.Fatal("reasoning summary missing from export") | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestTimelineExportIsExclusiveAndPrivate(t *testing.T) { | |
| 309 | + | if runtime.GOOS == "windows" { | |
| 310 | + | t.Skip("POSIX permissions") | |
| 311 | + | } | |
| 312 | + | dir := filepath.Join(t.TempDir(), "exports") | |
| 313 | + | view := newTimelineView() | |
| 314 | + | path, err := view.Export(dir, timelineTestData()) | |
| 315 | + | if err != nil { | |
| 316 | + | t.Fatal(err) | |
| 317 | + | } | |
| 318 | + | if info, err := os.Stat(dir); err != nil || info.Mode().Perm() != 0o700 { | |
| 319 | + | t.Fatalf("export dir mode = %v (%v), want 700", info.Mode().Perm(), err) | |
| 320 | + | } | |
| 321 | + | // A pre-planted symlink at the target name must not be followed. | |
| 322 | + | victim := filepath.Join(t.TempDir(), "victim") | |
| 323 | + | if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil { | |
| 324 | + | t.Fatal(err) | |
| 325 | + | } | |
| 326 | + | if err := os.Remove(path); err != nil { | |
| 327 | + | t.Fatal(err) | |
| 328 | + | } | |
| 329 | + | if err := os.Symlink(victim, path); err != nil { | |
| 330 | + | t.Skip("symlinks unavailable") | |
| 331 | + | } | |
| 332 | + | if _, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600); err == nil { | |
| 333 | + | t.Fatal("exclusive open unexpectedly succeeded on an existing symlink") | |
| 334 | + | } | |
| 335 | + | if got, _ := os.ReadFile(victim); string(got) != "keep" { | |
| 336 | + | t.Fatalf("victim overwritten: %q", got) | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func TestTimelineSnapshotHidesHostInternalMessages(t *testing.T) { | |
| 341 | + | ag := core.NewAgent(nil, "m", "system prompt", nil) | |
| 342 | + | ag.SetMessages([]provider.Message{ | |
| 343 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "real prompt"}}}, | |
| 344 | + | {Role: provider.RoleUser, Meta: map[string]string{goalContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden goal continuation"}}}, | |
| 345 | + | {Role: provider.RoleUser, Meta: map[string]string{autoCompactContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden compact continuation"}}}, | |
| 346 | + | {Role: provider.RoleUser, Meta: map[string]string{core.RepetitionGuardMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden repetition guard"}}}, | |
| 347 | + | }) | |
| 348 | + | i := &Interactive{agent: ag, timeline: newTimelineView()} | |
| 349 | + | i.mu.Lock() | |
| 350 | + | data := i.timelineDataLocked() | |
| 351 | + | i.mu.Unlock() | |
| 352 | + | if data.System != "system prompt" || len(data.Messages) != 1 { | |
| 353 | + | t.Fatalf("snapshot = system %q, %d messages", data.System, len(data.Messages)) | |
| 354 | + | } | |
| 355 | + | for _, e := range buildTimelineEntries(data) { | |
| 356 | + | if strings.Contains(e.Summary, "hidden") { | |
| 357 | + | t.Fatalf("internal message leaked: %+v", e) | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func TestTimelineCacheRebuildsOnlyWhenTranscriptChanges(t *testing.T) { | |
| 363 | + | ag := core.NewAgent(nil, "m", "sys", nil) | |
| 364 | + | ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}}) | |
| 365 | + | i := &Interactive{agent: ag, timeline: newTimelineView()} | |
| 366 | + | i.mu.Lock() | |
| 367 | + | data := i.timelineDataLocked() | |
| 368 | + | i.timeline.Open(data) | |
| 369 | + | first := i.timeline.entries(data) | |
| 370 | + | again := i.timeline.entries(i.timelineDataLocked()) | |
| 371 | + | i.mu.Unlock() | |
| 372 | + | if &first[0] != &again[0] { | |
| 373 | + | t.Fatal("unchanged transcript rebuilt the timeline") | |
| 374 | + | } | |
| 375 | + | ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "two"}}}}) | |
| 376 | + | i.mu.Lock() | |
| 377 | + | updated := i.timeline.entries(i.timelineDataLocked()) | |
| 378 | + | i.mu.Unlock() | |
| 379 | + | if !strings.Contains(updated[len(updated)-1].Summary, "two") { | |
| 380 | + | t.Fatalf("stale timeline after transcript change: %+v", updated) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func TestTimelineRendersInsideFloatingPane(t *testing.T) { | |
| 385 | + | data := timelineTestData() | |
| 386 | + | i := &Interactive{timeline: newTimelineView()} | |
| 387 | + | i.timeline.Open(data) | |
| 388 | + | pane := tui.FloatingPaneMaxRect(100, 30) | |
| 389 | + | lines := padDialogFrame(i.timeline.Render(tui.Dark, pane.ContentWidth(), pane.ContentHeight(), data)) | |
| 390 | + | title, body, _ := floatingDialogBody(lines) | |
| 391 | + | if title != "timeline" { | |
| 392 | + | t.Fatalf("pane title = %q, want timeline", title) | |
| 393 | + | } | |
| 394 | + | if len(body) > pane.ContentHeight() { | |
| 395 | + | t.Fatalf("body has %d rows, pane content height is %d", len(body), pane.ContentHeight()) | |
| 396 | + | } | |
| 397 | + | for _, row := range body { | |
| 398 | + | if w := runewidth.StringWidth(stripANSIBytes(row)); w > pane.ContentWidth() { | |
| 399 | + | t.Fatalf("row width %d exceeds pane %d: %q", w, pane.ContentWidth(), row) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | // A narrow drawer must still fit. | |
| 403 | + | narrow := tui.FloatingPaneMaxRect(40, 12) | |
| 404 | + | for _, row := range i.timeline.Render(tui.Dark, narrow.ContentWidth(), narrow.ContentHeight(), data) { | |
| 405 | + | if w := runewidth.StringWidth(stripANSIBytes(row)); w > narrow.ContentWidth() { | |
| 406 | + | t.Fatalf("narrow row width %d exceeds %d: %q", w, narrow.ContentWidth(), row) | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func TestTimelineKeysAreOwnedByTimelineAndCtrlCCloses(t *testing.T) { | |
| 412 | + | i := NewInteractive(InteractiveConfig{Agent: core.NewAgent(nil, "m", "", nil)}) | |
| 413 | + | i.openTimeline() | |
| 414 | + | if !i.timeline.Active() || !i.confirmChildActive() { | |
| 415 | + | t.Fatal("timeline is not registered as an active overlay") | |
| 416 | + | } | |
| 417 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'x'}) | |
| 418 | + | if i.ed.Value() != "" { | |
| 419 | + | t.Fatalf("key leaked to the main editor: %q", i.ed.Value()) | |
| 420 | + | } | |
| 421 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlC}) | |
| 422 | + | if i.timeline.Active() { | |
| 423 | + | t.Fatal("ctrl+c did not close the timeline") | |
| 424 | + | } | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func hostileTimelineData() timelineData { | |
| 428 | + | const hostile = "a\x1b]0;evil-title\x07b\x1b[31mred\x1b[0m\tc\rd\x1b" | |
| 429 | + | data := timelineData{ | |
| 430 | + | System: "sys " + hostile + "\nsecond\r\nthird\rfourth", | |
| 431 | + | Tools: []provider.Tool{{Name: "bash", Schema: json.RawMessage(`{"type":"object"}`)}}, | |
| 432 | + | Messages: []provider.Message{ | |
| 433 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: hostile + "\nmore"}}}, | |
| 434 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 435 | + | provider.ReasoningBlock{Summary: "think " + hostile}, | |
| 436 | + | provider.ToolCallBlock{ID: "c1", Name: "bash", Arguments: json.RawMessage(`{"command":"echo \u001b]0;evil-title\u0007 \t x\r y"}`)}, | |
| 437 | + | }}, | |
| 438 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |
| 439 | + | provider.ToolResultBlock{CallID: "c1", Content: []provider.Content{provider.TextBlock{Text: hostile}}}, | |
| 440 | + | }}, | |
| 441 | + | }, | |
| 442 | + | } | |
| 443 | + | return data | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func TestTimelineSanitizesUntrustedText(t *testing.T) { | |
| 447 | + | data := hostileTimelineData() | |
| 448 | + | view := newTimelineView() | |
| 449 | + | view.Open(data) | |
| 450 | + | view.searching = true | |
| 451 | + | view.filter = "q\x1b]0;evil-title\x07\tz" | |
| 452 | + | entries := buildTimelineEntries(data) | |
| 453 | + | view.filter = "" | |
| 454 | + | for _, width := range []int{20, 60, 120} { | |
| 455 | + | for tab := 0; tab < timelineTabCount; tab++ { | |
| 456 | + | view.tab = tab | |
| 457 | + | for cursor := range entries { | |
| 458 | + | view.cursor = cursor | |
| 459 | + | out := strings.Join(view.Render(tui.Dark, width, 30, data), "\n") | |
| 460 | + | plain := stripANSIBytes(out) | |
| 461 | + | // JSON payloads show control bytes as inert \uXXXX text, so | |
| 462 | + | // only raw controls are checked there; the summary tab and the | |
| 463 | + | // list rows must have the OSC payload removed entirely. | |
| 464 | + | bad := []string{"\r", "\t", "\a", "\x1b"} | |
| 465 | + | if tab == 0 { | |
| 466 | + | bad = append(bad, "evil-title") | |
| 467 | + | } | |
| 468 | + | for _, b := range bad { | |
| 469 | + | if strings.Contains(out, b) && b != "\x1b" || b == "\x1b" && strings.Contains(plain, b) { | |
| 470 | + | t.Fatalf("width %d tab %d entry %d leaked %q:\n%q", width, tab, cursor, b, plain) | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | for _, row := range strings.Split(plain, "\n") { | |
| 474 | + | if w := runewidth.StringWidth(row); w > width { | |
| 475 | + | t.Fatalf("width %d tab %d entry %d: row width %d: %q", width, tab, cursor, w, row) | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | // A typed or pasted filter cannot inject control sequences either. | |
| 482 | + | view.searching = true | |
| 483 | + | view.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "x\x1b]0;evil-title\x07\ty"}, data) | |
| 484 | + | view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\t'}, data) | |
| 485 | + | view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\r'}, data) | |
| 486 | + | shown := stripANSIBytes(strings.Join(view.Render(tui.Dark, 80, 30, data), "\n")) | |
| 487 | + | if strings.ContainsAny(view.filter, "\x1b\a\t\r") || strings.Contains(shown, "evil-title\x07") || strings.ContainsAny(shown, "\a\t\r") { | |
| 488 | + | t.Fatalf("filter leaked control text: filter=%q", view.filter) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func TestTimelineBodyFitsPaneAtEveryHeight(t *testing.T) { | |
| 493 | + | data := timelineTestData() | |
| 494 | + | data.Messages = append(data.Messages, provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 495 | + | provider.ReasoningBlock{Summary: strings.Repeat("long reasoning line\n", 40)}, | |
| 496 | + | }}) | |
| 497 | + | view := newTimelineView() | |
| 498 | + | view.Open(data) | |
| 499 | + | view.notice = "exported to ~/x.json" | |
| 500 | + | for _, searching := range []bool{false, true} { | |
| 501 | + | view.searching = searching | |
| 502 | + | for height := 1; height <= 40; height++ { | |
| 503 | + | for _, width := range []int{12, 30, 78, 120} { | |
| 504 | + | for tab := 0; tab < timelineTabCount; tab++ { | |
| 505 | + | view.tab = tab | |
| 506 | + | lines := padDialogFrame(view.Render(tui.Dark, width, height, data)) | |
| 507 | + | _, body, _ := floatingDialogBody(lines) | |
| 508 | + | if len(body) > height { | |
| 509 | + | t.Fatalf("height %d width %d tab %d: body has %d rows", height, width, tab, len(body)) | |
| 510 | + | } | |
| 511 | + | for _, row := range body { | |
| 512 | + | if w := runewidth.StringWidth(stripANSIBytes(row)); w > width { | |
| 513 | + | t.Fatalf("height %d width %d: row width %d: %q", height, width, w, row) | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | // Tiny panes still show the selected event before optional chrome. | |
| 521 | + | view.searching = false | |
| 522 | + | view.tab = 0 | |
| 523 | + | lines := padDialogFrame(view.Render(tui.Dark, 80, 3, data)) | |
| 524 | + | _, body, _ := floatingDialogBody(lines) | |
| 525 | + | if !strings.Contains(stripANSIBytes(strings.Join(body, "\n")), "REASONING") { | |
| 526 | + | t.Fatalf("selected event missing from a 3-row pane: %q", body) | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | func TestTimelineSnapshotCacheTracksPromptContentAndSkipsCopies(t *testing.T) { | |
| 531 | + | ag := core.NewAgent(nil, "m", "prompt-AAAA", core.Registry{}) | |
| 532 | + | ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}}) | |
| 533 | + | i := &Interactive{agent: ag, timeline: newTimelineView()} | |
| 534 | + | snapshot := func() timelineData { | |
| 535 | + | i.mu.Lock() | |
| 536 | + | defer i.mu.Unlock() | |
| 537 | + | return i.timelineDataLocked() | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | first := snapshot() | |
| 541 | + | again := snapshot() | |
| 542 | + | if &first.Messages[0] != &again.Messages[0] || !again.Est.ok { | |
| 543 | + | t.Fatal("unchanged transcript and prompt were re-copied") | |
| 544 | + | } | |
| 545 | + | // A same-length system prompt edit changes neither the transcript | |
| 546 | + | // revision nor the prompt length, yet must refresh the snapshot. | |
| 547 | + | ag.SetSystemPrompt("prompt-BBBB") | |
| 548 | + | if edited := snapshot(); edited.System != "prompt-BBBB" { | |
| 549 | + | t.Fatalf("stale system prompt after same-length edit: %q", edited.System) | |
| 550 | + | } | |
| 551 | + | // Same for a tool whose schema changes at equal length. | |
| 552 | + | ag.SetTools(core.Registry{"t": schemaTool{name: "t", schema: `{"a":1}`}}) | |
| 553 | + | if s := snapshot(); len(s.Tools) != 1 || string(s.Tools[0].Schema) != `{"a":1}` { | |
| 554 | + | t.Fatalf("tools = %+v", s.Tools) | |
| 555 | + | } | |
| 556 | + | ag.SetTools(core.Registry{"t": schemaTool{name: "t", schema: `{"a":2}`}}) | |
| 557 | + | if s := snapshot(); string(s.Tools[0].Schema) != `{"a":2}` { | |
| 558 | + | t.Fatalf("stale tool schema after same-length edit: %s", s.Tools[0].Schema) | |
| 559 | + | } | |
| 560 | + | // The context gauge is live and never cached. | |
| 561 | + | i.mu.Lock() | |
| 562 | + | i.lastCtxInput = 42 | |
| 563 | + | i.mu.Unlock() | |
| 564 | + | if snapshot().ContextUsed != 42 { | |
| 565 | + | t.Fatal("context usage was served from the cache") | |
| 566 | + | } | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | type schemaTool struct{ name, schema string } | |
| 570 | + | ||
| 571 | + | func (t schemaTool) Name() string { return t.name } | |
| 572 | + | func (t schemaTool) Description() string { return "d" } | |
| 573 | + | func (t schemaTool) Schema() json.RawMessage { return json.RawMessage(t.schema) } | |
| 574 | + | func (t schemaTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) { | |
| 575 | + | return core.ToolResult{}, nil | |
| 576 | + | } | |
M packages/agent/scheduler_runtime.go +20 -1
| @@ -12,6 +12,17 @@ import ( | |||
| 12 | 12 | "github.com/bnema/zut/packages/provider" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | + | // scheduledSessionRegistry keeps background runs from opening foreground | |
| 16 | + | // panels and waiting indefinitely for input from a hidden conversation. | |
| 17 | + | func scheduledSessionRegistry(registry core.Registry) core.Registry { | |
| 18 | + | for name, tool := range registry { | |
| 19 | + | if extensions.IsInteractiveTool(tool) { | |
| 20 | + | delete(registry, name) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | return registry | |
| 24 | + | } | |
| 25 | + | ||
| 15 | 26 | // runScheduledSession reconstructs an inactive session in the current zut | |
| 16 | 27 | // process and appends the resulting turn to its existing transcript. The | |
| 17 | 28 | // caller serializes this with session transitions before invoking it. | |
| @@ -44,7 +55,15 @@ func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, ba | |||
| 44 | 55 | if model == "" { | |
| 45 | 56 | model = base.Model | |
| 46 | 57 | } | |
| 47 | - | ag, _, _, err := buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry) | |
| 58 | + | // The shared manager may be attached to the foreground TUI, but this | |
| 59 | + | // reconstructed session has no visible user to answer interactive tools. | |
| 60 | + | backgroundRegistry := func(registry core.Registry) core.Registry { | |
| 61 | + | if prepareRegistry != nil { | |
| 62 | + | registry = prepareRegistry(registry) | |
| 63 | + | } | |
| 64 | + | return scheduledSessionRegistry(registry) | |
| 65 | + | } | |
| 66 | + | ag, _, _, err := buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, backgroundRegistry) | |
| 48 | 67 | if err != nil { | |
| 49 | 68 | return fmt.Errorf("build scheduled session agent: %w", err) | |
| 50 | 69 | } | |
A packages/agent/session_freshness.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/core" | |
| 10 | + | "github.com/bnema/zut/packages/provider" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // openOrCreateSessionState distinguishes creation from restoration even when | |
| 14 | + | // the restored transcript is empty. Fresh runs may preload pinned skills. | |
| 15 | + | func openOrCreateSessionState(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, bool, error) { | |
| 16 | + | if args.NoSess { | |
| 17 | + | ag.SessionID() | |
| 18 | + | return nil, true, nil | |
| 19 | + | } | |
| 20 | + | root := agentSessionsRoot(ZutHome(), args) | |
| 21 | + | // Explicit paths and UUIDs may identify empty sessions. Don't delete them | |
| 22 | + | // before deciding whether this is a restoration. | |
| 23 | + | if args.ResumeSessionID == "" && args.Session == "" { | |
| 24 | + | core.PruneEmptySessions(root, args.CWD) | |
| 25 | + | } | |
| 26 | + | var ( | |
| 27 | + | sess *core.Session | |
| 28 | + | messages []provider.Message | |
| 29 | + | err error | |
| 30 | + | fresh bool | |
| 31 | + | ) | |
| 32 | + | switch { | |
| 33 | + | case args.Session != "": | |
| 34 | + | sess, messages, err = core.OpenSession(args.Session) | |
| 35 | + | if errors.Is(err, os.ErrNotExist) { | |
| 36 | + | sess, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version) | |
| 37 | + | messages, fresh = nil, true | |
| 38 | + | } | |
| 39 | + | case args.Continue: | |
| 40 | + | if path := core.LatestSession(root, args.CWD); path != "" { | |
| 41 | + | sess, messages, err = core.OpenSession(path) | |
| 42 | + | } | |
| 43 | + | case args.Resume: | |
| 44 | + | var path string | |
| 45 | + | if args.ResumeSessionID != "" { | |
| 46 | + | path, err = core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID) | |
| 47 | + | if err != nil { | |
| 48 | + | return nil, false, err | |
| 49 | + | } | |
| 50 | + | if path == "" { | |
| 51 | + | return nil, false, fmt.Errorf("session %q not found", args.ResumeSessionID) | |
| 52 | + | } | |
| 53 | + | } else { | |
| 54 | + | path, err = pickSession(root, args.CWD) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, false, err | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | if path != "" { | |
| 60 | + | sess, messages, err = core.OpenSession(path) | |
| 61 | + | if err == nil && args.ResumeSessionID != "" && sess.ID != args.ResumeSessionID { | |
| 62 | + | return nil, false, errors.Join(fmt.Errorf("session %q changed during resume", args.ResumeSessionID), sess.Close()) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | if err != nil { | |
| 67 | + | return nil, false, err | |
| 68 | + | } | |
| 69 | + | if sess == nil { | |
| 70 | + | sess, err = core.NewSession(root, args.CWD, r.Provider, r.Model, version) | |
| 71 | + | if err != nil { | |
| 72 | + | return nil, false, err | |
| 73 | + | } | |
| 74 | + | fresh = true | |
| 75 | + | } | |
| 76 | + | if err := ag.BindSessionID(sess.Meta.ID); err != nil { | |
| 77 | + | return nil, false, errors.Join(err, sess.Close()) | |
| 78 | + | } | |
| 79 | + | ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset) | |
| 80 | + | if !fresh { | |
| 81 | + | ag.SetMessages(messages) | |
| 82 | + | if cumulative, last, err := core.SessionUsageDetail(sess.Path); err == nil { | |
| 83 | + | ag.SeedCost(cumulative) | |
| 84 | + | ag.SeedLastTurnUsage(last) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return sess, fresh, nil | |
| 88 | + | } | |
A packages/agent/session_freshness_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/core" | |
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestOpenOrCreateSessionStateFreshness(t *testing.T) { | |
| 13 | + | for _, name := range []string{"new", "no-session", "new-explicit", "existing-explicit", "continue-existing", "resume-id", "continue-missing"} { | |
| 14 | + | t.Run(name, func(t *testing.T) { | |
| 15 | + | home, cwd := t.TempDir(), t.TempDir() | |
| 16 | + | t.Setenv("ZUT_HOME", home) | |
| 17 | + | args := Args{CWD: cwd} | |
| 18 | + | resolved := Resolved{CWD: cwd, Provider: "test-provider", Model: "test-model"} | |
| 19 | + | wantFresh := true | |
| 20 | + | switch name { | |
| 21 | + | case "no-session": | |
| 22 | + | args.NoSess = true | |
| 23 | + | case "new-explicit": | |
| 24 | + | args.Session = filepath.Join(home, "explicit.jsonl") | |
| 25 | + | case "existing-explicit", "continue-existing", "resume-id": | |
| 26 | + | old, err := core.NewSession(home, cwd, resolved.Provider, resolved.Model, "test") | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if name != "existing-explicit" { | |
| 31 | + | if err := old.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "saved prompt"}}}); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | path, id := old.Path, old.ID | |
| 36 | + | if name == "existing-explicit" { | |
| 37 | + | // Keep the empty session open so its valid header remains; | |
| 38 | + | // restoration is not inferred from message count. | |
| 39 | + | t.Cleanup(func() { _ = old.Close() }) | |
| 40 | + | args.Session = path | |
| 41 | + | } else { | |
| 42 | + | if err := old.Close(); err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | if name == "continue-existing" { | |
| 46 | + | args.Continue = true | |
| 47 | + | } else { | |
| 48 | + | args.Resume, args.ResumeSessionID = true, id | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | wantFresh = false | |
| 52 | + | case "continue-missing": | |
| 53 | + | args.Continue = true | |
| 54 | + | } | |
| 55 | + | ag := core.NewAgent(nil, resolved.Model, "system", core.Registry{}) | |
| 56 | + | sess, fresh, err := openOrCreateSessionState(context.Background(), args, resolved, ag, "test") | |
| 57 | + | if err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | if sess != nil { | |
| 61 | + | defer sess.Close() | |
| 62 | + | } | |
| 63 | + | if fresh != wantFresh { | |
| 64 | + | t.Fatalf("fresh=%v, want %v", fresh, wantFresh) | |
| 65 | + | } | |
| 66 | + | if args.NoSess && sess != nil { | |
| 67 | + | t.Fatal("session created with persistence disabled") | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
A packages/agent/skill_pins.go +223 -0
| @@ -0,0 +1,223 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "slices" | |
| 12 | + | "sort" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // skillPinsFileName lives directly under $ZUT_HOME. Pins are local | |
| 20 | + | // preferences, so even project pins never write into a checkout. | |
| 21 | + | const skillPinsFileName = "skill-pins.json" | |
| 22 | + | ||
| 23 | + | type skillPinsFile struct { | |
| 24 | + | Global []string `json:"global,omitempty"` | |
| 25 | + | Projects map[string][]string `json:"projects,omitempty"` | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // skillPinsMu serializes read-modify-write cycles within this process. | |
| 29 | + | // Concurrent zut processes are not merged; the last atomic rename wins. | |
| 30 | + | var skillPinsMu sync.Mutex | |
| 31 | + | ||
| 32 | + | // userSkillSnapshot rediscovers the user-facing skills with the same | |
| 33 | + | // precedence as agent construction (user/project locations, then | |
| 34 | + | // extension-bundled skills). Built-ins are not pinnable and are filtered out. | |
| 35 | + | // bundled is the running extension manager's skill list, or nil. | |
| 36 | + | func userSkillSnapshot(args Args, cwd string, bundled []*skills.Skill) []*skills.Skill { | |
| 37 | + | if args.NoSkill { | |
| 38 | + | return nil | |
| 39 | + | } | |
| 40 | + | userHome, _ := os.UserHomeDir() | |
| 41 | + | list, _ := skills.Discover(ZutHome(), cwd, userHome, args.WithSkills) | |
| 42 | + | list = mergeExtensionSkills(skills.NewTool(list), bundled) | |
| 43 | + | return skills.VisibleSkills(list) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // preloadSkillPins folds the pinned skills' full bodies into the first prompt | |
| 47 | + | // of a fresh non-interactive session. Resumed sessions and --no-skill runs | |
| 48 | + | // return the prompt unchanged. Problems are reported as warnings on | |
| 49 | + | // diagnostics (stderr), never on protocol output. | |
| 50 | + | func preloadSkillPins(args Args, fresh bool, prompt string, bundled []*skills.Skill, diagnostics io.Writer) string { | |
| 51 | + | if !fresh || args.NoSkill { | |
| 52 | + | return prompt | |
| 53 | + | } | |
| 54 | + | pins, err := loadSkillPins(args.CWD) | |
| 55 | + | if err != nil { | |
| 56 | + | fmt.Fprintln(diagnostics, "warning:", err) | |
| 57 | + | return prompt | |
| 58 | + | } | |
| 59 | + | selected, warnings := pins.Resolve(userSkillSnapshot(args, args.CWD, bundled)) | |
| 60 | + | for _, warning := range warnings { | |
| 61 | + | fmt.Fprintln(diagnostics, "warning:", warning) | |
| 62 | + | } | |
| 63 | + | return skills.PreloadPrompt(selected, prompt) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // skillPinProject returns the canonical project key: the absolute starting | |
| 67 | + | // directory with symlinks resolved when possible (not the Git root). | |
| 68 | + | func skillPinProject(cwd string) (string, error) { | |
| 69 | + | path, err := filepath.Abs(cwd) | |
| 70 | + | if err != nil { | |
| 71 | + | return "", err | |
| 72 | + | } | |
| 73 | + | if resolved, err := filepath.EvalSymlinks(path); err == nil { | |
| 74 | + | path = resolved | |
| 75 | + | } | |
| 76 | + | return filepath.Clean(path), nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // normalizePinNames drops blanks and duplicates and sorts, so unions and | |
| 80 | + | // saved files are deterministic. | |
| 81 | + | func normalizePinNames(names []string) []string { | |
| 82 | + | out := make([]string, 0, len(names)) | |
| 83 | + | for _, n := range names { | |
| 84 | + | if n = strings.TrimSpace(n); n != "" { | |
| 85 | + | out = append(out, n) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | sort.Strings(out) | |
| 89 | + | return slices.Compact(out) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func readSkillPins() (skillPinsFile, error) { | |
| 93 | + | var pins skillPinsFile | |
| 94 | + | data, err := os.ReadFile(filepath.Join(ZutHome(), skillPinsFileName)) | |
| 95 | + | if errors.Is(err, os.ErrNotExist) { | |
| 96 | + | return pins, nil | |
| 97 | + | } | |
| 98 | + | if err != nil { | |
| 99 | + | return pins, fmt.Errorf("read skill pins: %w", err) | |
| 100 | + | } | |
| 101 | + | // Do not echo file contents in diagnostics. The root must be a JSON | |
| 102 | + | // object: json.Unmarshal accepts null (a silent no-op), which would let a | |
| 103 | + | // toggle replace a file that is not a preferences document. Unknown | |
| 104 | + | // top-level keys fail closed too, because saving would drop them. | |
| 105 | + | invalid := fmt.Errorf("invalid %s: expected an object with only \"global\" and \"projects\" (fix or remove it; it will not be overwritten)", skillPinsFileName) | |
| 106 | + | if !isJSONObject(data) { | |
| 107 | + | return skillPinsFile{}, invalid | |
| 108 | + | } | |
| 109 | + | dec := json.NewDecoder(bytes.NewReader(data)) | |
| 110 | + | dec.DisallowUnknownFields() | |
| 111 | + | if dec.Decode(&pins) != nil { | |
| 112 | + | return skillPinsFile{}, invalid | |
| 113 | + | } | |
| 114 | + | if _, err := dec.Token(); err != io.EOF { // trailing data | |
| 115 | + | return skillPinsFile{}, invalid | |
| 116 | + | } | |
| 117 | + | return pins, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func isJSONObject(data []byte) bool { | |
| 121 | + | trimmed := bytes.TrimSpace(data) | |
| 122 | + | return len(trimmed) > 0 && trimmed[0] == '{' | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // loadSkillPins returns the global and current-project pins. On a malformed | |
| 126 | + | // or unreadable file it returns the error and no pins. | |
| 127 | + | func loadSkillPins(cwd string) (skills.Pins, error) { | |
| 128 | + | skillPinsMu.Lock() | |
| 129 | + | defer skillPinsMu.Unlock() | |
| 130 | + | project, err := skillPinProject(cwd) | |
| 131 | + | if err != nil { | |
| 132 | + | return skills.Pins{}, err | |
| 133 | + | } | |
| 134 | + | pins, err := readSkillPins() | |
| 135 | + | if err != nil { | |
| 136 | + | return skills.Pins{}, err | |
| 137 | + | } | |
| 138 | + | return skills.Pins{ | |
| 139 | + | Global: normalizePinNames(pins.Global), | |
| 140 | + | Project: normalizePinNames(pins.Projects[project]), | |
| 141 | + | }, nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // toggleSkillPin flips one name in the global or current-project scope and | |
| 145 | + | // saves via temp-file replacement. A malformed existing file is never | |
| 146 | + | // overwritten. | |
| 147 | + | func toggleSkillPin(cwd, name string, global bool) error { | |
| 148 | + | name = strings.TrimSpace(name) | |
| 149 | + | if name == "" { | |
| 150 | + | return fmt.Errorf("skill name is required") | |
| 151 | + | } | |
| 152 | + | skillPinsMu.Lock() | |
| 153 | + | defer skillPinsMu.Unlock() | |
| 154 | + | project, err := skillPinProject(cwd) | |
| 155 | + | if err != nil { | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | // A symlinked preferences file is read-only: renaming over it would | |
| 159 | + | // silently replace the link, and writing through it would modify a file | |
| 160 | + | // zut does not own. Loading may still follow it. | |
| 161 | + | path := filepath.Join(ZutHome(), skillPinsFileName) | |
| 162 | + | if info, lerr := os.Lstat(path); lerr == nil && info.Mode()&os.ModeSymlink != 0 { | |
| 163 | + | return fmt.Errorf("%s is a symbolic link; skill pins can be read from it but not saved (replace it with a regular file to change pins)", skillPinsFileName) | |
| 164 | + | } | |
| 165 | + | pins, err := readSkillPins() | |
| 166 | + | if err != nil { | |
| 167 | + | return err | |
| 168 | + | } | |
| 169 | + | toggle := func(names []string) []string { | |
| 170 | + | names = normalizePinNames(names) | |
| 171 | + | if slices.Contains(names, name) { | |
| 172 | + | return slices.DeleteFunc(names, func(n string) bool { return n == name }) | |
| 173 | + | } | |
| 174 | + | return normalizePinNames(append(names, name)) | |
| 175 | + | } | |
| 176 | + | if global { | |
| 177 | + | pins.Global = toggle(pins.Global) | |
| 178 | + | } else { | |
| 179 | + | if pins.Projects == nil { | |
| 180 | + | pins.Projects = make(map[string][]string) | |
| 181 | + | } | |
| 182 | + | pins.Projects[project] = toggle(pins.Projects[project]) | |
| 183 | + | if len(pins.Projects[project]) == 0 { | |
| 184 | + | delete(pins.Projects, project) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | data, err := json.MarshalIndent(pins, "", " ") | |
| 188 | + | if err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | return writeSkillPinsAtomic(path, append(data, '\n')) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // writeSkillPinsAtomic replaces the preferences file with data through a | |
| 195 | + | // same-directory temp file (mode 0600 from CreateTemp), so readers never see | |
| 196 | + | // a partial file. | |
| 197 | + | func writeSkillPinsAtomic(path string, data []byte) error { | |
| 198 | + | dir := filepath.Dir(path) | |
| 199 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 200 | + | return err | |
| 201 | + | } | |
| 202 | + | f, err := os.CreateTemp(dir, ".skill-pins-*") | |
| 203 | + | if err != nil { | |
| 204 | + | return err | |
| 205 | + | } | |
| 206 | + | tmp := f.Name() | |
| 207 | + | defer os.Remove(tmp) // no-op after a successful rename | |
| 208 | + | if _, err := f.Write(data); err != nil { | |
| 209 | + | f.Close() | |
| 210 | + | return err | |
| 211 | + | } | |
| 212 | + | if err := f.Sync(); err != nil { | |
| 213 | + | f.Close() | |
| 214 | + | return err | |
| 215 | + | } | |
| 216 | + | if err := f.Close(); err != nil { | |
| 217 | + | return err | |
| 218 | + | } | |
| 219 | + | if err := os.Rename(tmp, path); err != nil { | |
| 220 | + | return fmt.Errorf("save skill pins: %w", err) | |
| 221 | + | } | |
| 222 | + | return nil | |
| 223 | + | } | |
A packages/agent/skill_pins_modes_test.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "io" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zut/packages/core" | |
| 15 | + | "github.com/bnema/zut/packages/provider" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestHeadlessModesPreloadSkillPinsAndPersistOnce(t *testing.T) { | |
| 19 | + | modes := []struct { | |
| 20 | + | name string | |
| 21 | + | run func(context.Context, Args, string) error | |
| 22 | + | }{ | |
| 23 | + | {"print", runPrintMode}, {"stream", runStreamMode}, {"json", runJSONMode}, | |
| 24 | + | } | |
| 25 | + | for _, mode := range modes { | |
| 26 | + | t.Run(mode.name, func(t *testing.T) { | |
| 27 | + | home, cwd := t.TempDir(), t.TempDir() | |
| 28 | + | t.Setenv("ZUT_HOME", home) | |
| 29 | + | t.Setenv("HOME", t.TempDir()) | |
| 30 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 31 | + | t.Setenv("ZUT_AGENT_PROFILES", "") | |
| 32 | + | dir := filepath.Join(cwd, ".zut", "skills", "review") | |
| 33 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\ndescription: Review things.\n---\nPINNED_INSTRUCTIONS\n"), 0o600); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | if err := toggleSkillPin(cwd, "review", true); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | requests := make(chan string, 3) | |
| 43 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 44 | + | var request struct { | |
| 45 | + | Messages []struct { | |
| 46 | + | Role string `json:"role"` | |
| 47 | + | Content json.RawMessage `json:"content"` | |
| 48 | + | } `json:"messages"` | |
| 49 | + | } | |
| 50 | + | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { | |
| 51 | + | t.Error(err) | |
| 52 | + | http.Error(w, "bad request", http.StatusBadRequest) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | for index := len(request.Messages) - 1; index >= 0; index-- { | |
| 56 | + | if request.Messages[index].Role == "user" { | |
| 57 | + | requests <- string(request.Messages[index].Content) | |
| 58 | + | break | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | w.Header().Set("content-type", "text/event-stream") | |
| 62 | + | _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") | |
| 63 | + | })) | |
| 64 | + | defer server.Close() | |
| 65 | + | stdin, err := os.Open(os.DevNull) | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | oldStdin := os.Stdin | |
| 70 | + | os.Stdin = stdin | |
| 71 | + | t.Cleanup(func() { os.Stdin = oldStdin; _ = stdin.Close() }) | |
| 72 | + | args := Args{CWD: cwd, Provider: "openai", Model: "gpt-5", APIKey: "test-key", BaseURL: server.URL, Prompt: "first prompt", NoExt: true, WithSkills: true} | |
| 73 | + | if _, err := captureTestStdout(t, func() error { return mode.run(context.Background(), args, "test") }); err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | select { | |
| 77 | + | case prompt := <-requests: | |
| 78 | + | if !strings.Contains(prompt, "PINNED_INSTRUCTIONS") { | |
| 79 | + | t.Fatalf("provider did not receive pinned body: %s", prompt) | |
| 80 | + | } | |
| 81 | + | default: | |
| 82 | + | t.Fatal("no provider request") | |
| 83 | + | } | |
| 84 | + | path := core.LatestSession(home, cwd) | |
| 85 | + | if path == "" { | |
| 86 | + | t.Fatal("no persisted session") | |
| 87 | + | } | |
| 88 | + | args.Continue, args.Prompt = true, "second prompt" | |
| 89 | + | if _, err := captureTestStdout(t, func() error { return mode.run(context.Background(), args, "test") }); err != nil { | |
| 90 | + | t.Fatal(err) | |
| 91 | + | } | |
| 92 | + | select { | |
| 93 | + | case prompt := <-requests: | |
| 94 | + | if strings.Contains(prompt, "PINNED_INSTRUCTIONS") { | |
| 95 | + | t.Fatalf("resumed prompt reloaded pins: %s", prompt) | |
| 96 | + | } | |
| 97 | + | default: | |
| 98 | + | t.Fatal("no resumed provider request") | |
| 99 | + | } | |
| 100 | + | session, messages, err := core.OpenSession(path) | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | defer session.Close() | |
| 105 | + | count := 0 | |
| 106 | + | for _, message := range messages { | |
| 107 | + | if message.Role != provider.RoleUser { | |
| 108 | + | continue | |
| 109 | + | } | |
| 110 | + | for _, content := range message.Content { | |
| 111 | + | if text, ok := content.(provider.TextBlock); ok { | |
| 112 | + | count += strings.Count(text.Text, "PINNED_INSTRUCTIONS") | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | if count != 1 { | |
| 117 | + | t.Fatalf("persisted pinned bodies=%d, want 1", count) | |
| 118 | + | } | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | } | |
A packages/agent/skill_pins_test.go +285 -0
| @@ -0,0 +1,285 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zut/packages/agent/skills" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestSkillPinsPersistIndependentScopes(t *testing.T) { | |
| 18 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 19 | + | a, b := t.TempDir(), t.TempDir() | |
| 20 | + | for _, pin := range []struct { | |
| 21 | + | cwd, name string | |
| 22 | + | global bool | |
| 23 | + | }{ | |
| 24 | + | {a, "review", false}, {a, "review", true}, {b, "other", false}, | |
| 25 | + | } { | |
| 26 | + | if err := toggleSkillPin(pin.cwd, pin.name, pin.global); err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | if err := toggleSkillPin(a, "review", false); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | pins, err := loadSkillPins(a) | |
| 34 | + | if err != nil || len(pins.Project) != 0 || !slices.Equal(pins.Global, []string{"review"}) { | |
| 35 | + | t.Fatalf("pins = %+v, %v", pins, err) | |
| 36 | + | } | |
| 37 | + | pins, err = loadSkillPins(b) | |
| 38 | + | if err != nil || !slices.Equal(pins.Project, []string{"other"}) || !slices.Equal(pins.Global, []string{"review"}) { | |
| 39 | + | t.Fatalf("pins = %+v, %v", pins, err) | |
| 40 | + | } | |
| 41 | + | info, err := os.Stat(filepath.Join(ZutHome(), skillPinsFileName)) | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { | |
| 46 | + | t.Fatalf("mode = %o", info.Mode().Perm()) | |
| 47 | + | } | |
| 48 | + | if entries, err := os.ReadDir(a); err != nil || len(entries) != 0 { | |
| 49 | + | t.Fatalf("project was modified: %v, %v", entries, err) | |
| 50 | + | } | |
| 51 | + | // No temp files are left next to the preferences. | |
| 52 | + | entries, err := os.ReadDir(ZutHome()) | |
| 53 | + | if err != nil || len(entries) != 1 { | |
| 54 | + | t.Fatalf("home entries = %v, %v", entries, err) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestSkillPinsSavedSortedAndDeduplicated(t *testing.T) { | |
| 59 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 60 | + | cwd := t.TempDir() | |
| 61 | + | // A hand-edited file with duplicates and unsorted names loads normalized. | |
| 62 | + | key, err := skillPinProject(cwd) | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | raw, _ := json.Marshal(skillPinsFile{Global: []string{"b", "a", "b", " "}, Projects: map[string][]string{key: {"z", "a", "a"}}}) | |
| 67 | + | if err := os.WriteFile(filepath.Join(ZutHome(), skillPinsFileName), raw, 0o600); err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | pins, err := loadSkillPins(cwd) | |
| 71 | + | if err != nil || !slices.Equal(pins.Global, []string{"a", "b"}) || !slices.Equal(pins.Project, []string{"a", "z"}) { | |
| 72 | + | t.Fatalf("pins = %+v, %v", pins, err) | |
| 73 | + | } | |
| 74 | + | if err := toggleSkillPin(cwd, "m", false); err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | pins, _ = loadSkillPins(cwd) | |
| 78 | + | if !slices.Equal(pins.Project, []string{"a", "m", "z"}) { | |
| 79 | + | t.Fatalf("project = %v", pins.Project) | |
| 80 | + | } | |
| 81 | + | selected, warnings := pins.Resolve([]*skills.Skill{{Name: "z"}, {Name: "a"}, {Name: "b"}}) | |
| 82 | + | if len(warnings) != 1 || len(selected) != 3 || selected[0].Name != "a" || selected[1].Name != "b" || selected[2].Name != "z" { | |
| 83 | + | t.Fatalf("selected=%v warnings=%v", selected, warnings) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestSkillPinsDirectoryAliases(t *testing.T) { | |
| 88 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 89 | + | root := t.TempDir() | |
| 90 | + | target, alias := filepath.Join(root, "target"), filepath.Join(root, "alias") | |
| 91 | + | if err := os.Mkdir(target, 0o700); err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | if err := os.Symlink(target, alias); err != nil { | |
| 95 | + | t.Skipf("symlink unavailable: %v", err) | |
| 96 | + | } | |
| 97 | + | if err := toggleSkillPin(target, "review", false); err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | pins, err := loadSkillPins(alias) | |
| 101 | + | if err != nil || !slices.Equal(pins.Project, []string{"review"}) { | |
| 102 | + | t.Fatalf("alias pins = %+v, %v", pins, err) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestSkillPinsRejectCorruptFileWithoutOverwriting(t *testing.T) { | |
| 107 | + | for _, original := range []string{ | |
| 108 | + | `{"global":["review"],"projects":42}`, `not json`, ``, `null`, ` null `, `[]`, `["review"]`, `42`, `"review"`, `true`, | |
| 109 | + | `{"global":["review"]} trailing`, | |
| 110 | + | `{"global":["review"],"future":{"x":1}}`, `{"version":2,"global":[]}`, `{"projects":{},"extra":null}`, | |
| 111 | + | } { | |
| 112 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 113 | + | path := filepath.Join(ZutHome(), skillPinsFileName) | |
| 114 | + | if err := os.WriteFile(path, []byte(original), 0o600); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | pins, err := loadSkillPins(t.TempDir()) | |
| 118 | + | if err == nil || len(pins.Global) != 0 { | |
| 119 | + | t.Fatalf("%q: pins = %+v, err = %v", original, pins, err) | |
| 120 | + | } | |
| 121 | + | if strings.Contains(err.Error(), "review") { | |
| 122 | + | t.Fatalf("diagnostic echoes file contents: %v", err) | |
| 123 | + | } | |
| 124 | + | if err := toggleSkillPin(t.TempDir(), "new", true); err == nil { | |
| 125 | + | t.Fatal("corrupt file overwritten") | |
| 126 | + | } | |
| 127 | + | got, err := os.ReadFile(path) | |
| 128 | + | if err != nil || !bytes.Equal(got, []byte(original)) { | |
| 129 | + | t.Fatalf("file changed: %q, %v", got, err) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestSkillPinsConcurrentTogglesKeepFileValid(t *testing.T) { | |
| 135 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 136 | + | cwd := t.TempDir() | |
| 137 | + | var wg sync.WaitGroup | |
| 138 | + | names := []string{"a", "b", "c", "d", "e", "f"} | |
| 139 | + | for _, n := range names { | |
| 140 | + | wg.Add(1) | |
| 141 | + | go func() { | |
| 142 | + | defer wg.Done() | |
| 143 | + | if err := toggleSkillPin(cwd, n, true); err != nil { | |
| 144 | + | t.Error(err) | |
| 145 | + | } | |
| 146 | + | }() | |
| 147 | + | } | |
| 148 | + | wg.Wait() | |
| 149 | + | pins, err := loadSkillPins(cwd) | |
| 150 | + | if err != nil || !slices.Equal(pins.Global, names) { | |
| 151 | + | t.Fatalf("pins = %+v, %v", pins, err) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestPreloadSkillPinsFreshOnlyAndNoSkill(t *testing.T) { | |
| 156 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 157 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 158 | + | t.Setenv("HOME", t.TempDir()) | |
| 159 | + | cwd := t.TempDir() | |
| 160 | + | dir := filepath.Join(cwd, ".zut", "skills", "review") | |
| 161 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 162 | + | t.Fatal(err) | |
| 163 | + | } | |
| 164 | + | if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\ndescription: Review things.\ndisable-model-invocation: true\n---\nInspect carefully.\n"), 0o600); err != nil { | |
| 165 | + | t.Fatal(err) | |
| 166 | + | } | |
| 167 | + | for _, pin := range []struct { | |
| 168 | + | name string | |
| 169 | + | global bool | |
| 170 | + | }{{"review", true}, {"review", false}, {"missing", false}} { | |
| 171 | + | if err := toggleSkillPin(cwd, pin.name, pin.global); err != nil { | |
| 172 | + | t.Fatal(err) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | for _, tt := range []struct { | |
| 176 | + | name string | |
| 177 | + | fresh, disabled bool | |
| 178 | + | }{ | |
| 179 | + | {"fresh", true, false}, {"resumed", false, false}, {"disabled", true, true}, | |
| 180 | + | } { | |
| 181 | + | t.Run(tt.name, func(t *testing.T) { | |
| 182 | + | var diagnostics bytes.Buffer | |
| 183 | + | args := Args{CWD: cwd, WithSkills: true, NoSkill: tt.disabled} | |
| 184 | + | got := preloadSkillPins(args, tt.fresh, "question", nil, &diagnostics) | |
| 185 | + | if tt.fresh && !tt.disabled { | |
| 186 | + | if strings.Count(got, "# Skill: review") != 1 || !strings.Contains(got, "Inspect carefully.") || !strings.HasSuffix(got, "User request:\nquestion") { | |
| 187 | + | t.Fatal(got) | |
| 188 | + | } | |
| 189 | + | if !strings.Contains(diagnostics.String(), "missing") { | |
| 190 | + | t.Fatal("missing warning") | |
| 191 | + | } | |
| 192 | + | } else if got != "question" || diagnostics.Len() != 0 { | |
| 193 | + | t.Fatalf("prompt = %q, warnings = %q", got, diagnostics.String()) | |
| 194 | + | } | |
| 195 | + | }) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestPreloadSkillPinsMalformedFileWarnsAndKeepsPrompt(t *testing.T) { | |
| 200 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 201 | + | if err := os.WriteFile(filepath.Join(ZutHome(), skillPinsFileName), []byte("{"), 0o600); err != nil { | |
| 202 | + | t.Fatal(err) | |
| 203 | + | } | |
| 204 | + | var diagnostics bytes.Buffer | |
| 205 | + | got := preloadSkillPins(Args{CWD: t.TempDir(), WithSkills: true}, true, "q", nil, &diagnostics) | |
| 206 | + | if got != "q" || !strings.Contains(diagnostics.String(), "invalid") { | |
| 207 | + | t.Fatalf("got=%q diagnostics=%q", got, diagnostics.String()) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestPreloadSkillPinsUsesExtensionSkills(t *testing.T) { | |
| 212 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 213 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 214 | + | t.Setenv("HOME", t.TempDir()) | |
| 215 | + | cwd := t.TempDir() | |
| 216 | + | if err := toggleSkillPin(cwd, "ext-skill", true); err != nil { | |
| 217 | + | t.Fatal(err) | |
| 218 | + | } | |
| 219 | + | bundled := []*skills.Skill{{Name: "ext-skill", Body: "from extension", Source: "extension x"}} | |
| 220 | + | got := preloadSkillPins(Args{CWD: cwd, WithSkills: true}, true, "q", bundled, &bytes.Buffer{}) | |
| 221 | + | if !strings.Contains(got, "from extension") { | |
| 222 | + | t.Fatal(got) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestSkillPinsSymlinkedFileIsReadableButNeverReplaced(t *testing.T) { | |
| 227 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 228 | + | target := filepath.Join(t.TempDir(), "dotfiles-skill-pins.json") | |
| 229 | + | original := []byte("{\"global\":[\"review\"]}\n") | |
| 230 | + | if err := os.WriteFile(target, original, 0o644); err != nil { | |
| 231 | + | t.Fatal(err) | |
| 232 | + | } | |
| 233 | + | link := filepath.Join(ZutHome(), skillPinsFileName) | |
| 234 | + | if err := os.Symlink(target, link); err != nil { | |
| 235 | + | t.Skipf("symlink unavailable: %v", err) | |
| 236 | + | } | |
| 237 | + | pins, err := loadSkillPins(t.TempDir()) | |
| 238 | + | if err != nil || !slices.Equal(pins.Global, []string{"review"}) { | |
| 239 | + | t.Fatalf("read through link: %+v, %v", pins, err) | |
| 240 | + | } | |
| 241 | + | err = toggleSkillPin(t.TempDir(), "other", true) | |
| 242 | + | if err == nil || !strings.Contains(err.Error(), "symbolic link") { | |
| 243 | + | t.Fatalf("err = %v", err) | |
| 244 | + | } | |
| 245 | + | if info, lerr := os.Lstat(link); lerr != nil || info.Mode()&os.ModeSymlink == 0 { | |
| 246 | + | t.Fatalf("link was replaced: %v, %v", info, lerr) | |
| 247 | + | } | |
| 248 | + | if got, _ := os.ReadFile(target); !bytes.Equal(got, original) { | |
| 249 | + | t.Fatalf("target changed: %q", got) | |
| 250 | + | } | |
| 251 | + | entries, _ := os.ReadDir(ZutHome()) | |
| 252 | + | if len(entries) != 1 { | |
| 253 | + | t.Fatalf("temp file left behind: %v", entries) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestSkillPinsUnknownFieldsFailClosedWithClearError(t *testing.T) { | |
| 258 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 259 | + | path := filepath.Join(ZutHome(), skillPinsFileName) | |
| 260 | + | original := []byte(`{"global":["a"],"notes":"keep me"}`) | |
| 261 | + | if err := os.WriteFile(path, original, 0o600); err != nil { | |
| 262 | + | t.Fatal(err) | |
| 263 | + | } | |
| 264 | + | _, err := loadSkillPins(t.TempDir()) | |
| 265 | + | if err == nil || !strings.Contains(err.Error(), "global") || strings.Contains(err.Error(), "keep me") { | |
| 266 | + | t.Fatalf("err = %v", err) | |
| 267 | + | } | |
| 268 | + | if err := toggleSkillPin(t.TempDir(), "b", false); err == nil { | |
| 269 | + | t.Fatal("toggle accepted unknown fields") | |
| 270 | + | } | |
| 271 | + | if got, _ := os.ReadFile(path); !bytes.Equal(got, original) { | |
| 272 | + | t.Fatalf("file changed: %q", got) | |
| 273 | + | } | |
| 274 | + | // The documented schema still round-trips. | |
| 275 | + | if err := os.WriteFile(path, []byte(`{"global":["a"],"projects":{"/x":["b"]}}`), 0o600); err != nil { | |
| 276 | + | t.Fatal(err) | |
| 277 | + | } | |
| 278 | + | if err := toggleSkillPin(t.TempDir(), "c", true); err != nil { | |
| 279 | + | t.Fatal(err) | |
| 280 | + | } | |
| 281 | + | raw, _ := os.ReadFile(path) | |
| 282 | + | if !strings.Contains(string(raw), `"/x"`) { | |
| 283 | + | t.Fatalf("other project pins lost: %s", raw) | |
| 284 | + | } | |
| 285 | + | } | |
A packages/agent/skills/pins.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package skills | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sort" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Pins records independent global and project selections by discovered name. | |
| 10 | + | type Pins struct { | |
| 11 | + | Global []string `json:"global,omitempty"` | |
| 12 | + | Project []string `json:"project,omitempty"` | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // Resolve returns each available pinned skill once, ordered by canonical | |
| 16 | + | // skill name (then path). A pin may be a skill's name or one of its aliases; | |
| 17 | + | // two pins that reach the same skill (for example a canonical name and its | |
| 18 | + | // slash alias, or the same name in both scopes) select it once. Names that | |
| 19 | + | // match nothing produce one warning each, in name order. | |
| 20 | + | func (p Pins) Resolve(available []*Skill) ([]*Skill, []string) { | |
| 21 | + | names := make(map[string]bool) | |
| 22 | + | for _, name := range append(append([]string(nil), p.Global...), p.Project...) { | |
| 23 | + | if name != "" { | |
| 24 | + | names[name] = true | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | sorted := make([]string, 0, len(names)) | |
| 28 | + | for name := range names { | |
| 29 | + | sorted = append(sorted, name) | |
| 30 | + | } | |
| 31 | + | sort.Strings(sorted) | |
| 32 | + | var selected []*Skill | |
| 33 | + | chosen := make(map[*Skill]bool) | |
| 34 | + | var warnings []string | |
| 35 | + | for _, name := range sorted { | |
| 36 | + | s := FindByName(available, name) | |
| 37 | + | if s == nil { | |
| 38 | + | warnings = append(warnings, fmt.Sprintf("pinned skill %q is unavailable", name)) | |
| 39 | + | continue | |
| 40 | + | } | |
| 41 | + | if !chosen[s] { | |
| 42 | + | chosen[s] = true | |
| 43 | + | selected = append(selected, s) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | sort.SliceStable(selected, func(i, j int) bool { | |
| 47 | + | if selected[i].Name != selected[j].Name { | |
| 48 | + | return selected[i].Name < selected[j].Name | |
| 49 | + | } | |
| 50 | + | return selected[i].Path < selected[j].Path | |
| 51 | + | }) | |
| 52 | + | return selected, warnings | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Scopes reports which scopes pin s, resolving aliases the same way Resolve | |
| 56 | + | // does: a pin counts when its name resolves to s among available. | |
| 57 | + | func (p Pins) Scopes(available []*Skill, s *Skill) (project, global bool) { | |
| 58 | + | hit := func(names []string) bool { | |
| 59 | + | for _, name := range names { | |
| 60 | + | if name != "" && FindByName(available, name) == s { | |
| 61 | + | return true | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | return false | |
| 65 | + | } | |
| 66 | + | return hit(p.Project), hit(p.Global) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // PreloadPrompt includes pinned instructions with the first real request. | |
| 70 | + | // It creates no synthetic tool calls and does not start a model turn. | |
| 71 | + | func PreloadPrompt(selected []*Skill, request string) string { | |
| 72 | + | if len(selected) == 0 { | |
| 73 | + | return request | |
| 74 | + | } | |
| 75 | + | var b strings.Builder | |
| 76 | + | b.WriteString("Pinned skills for this conversation. Apply these instructions where relevant to the user's requests.\n\n") | |
| 77 | + | for _, s := range selected { | |
| 78 | + | b.WriteString(InvocationPrompt(s, "")) | |
| 79 | + | b.WriteString("\n\n") | |
| 80 | + | } | |
| 81 | + | b.WriteString("---\n\nUser request:\n") | |
| 82 | + | b.WriteString(request) | |
| 83 | + | return b.String() | |
| 84 | + | } | |
A packages/agent/skills/pins_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package skills | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPinsResolveUnionAndMissing(t *testing.T) { | |
| 10 | + | a := &Skill{Name: "a", Body: "first"} | |
| 11 | + | b := &Skill{Name: "ext:b", Body: "second", DisableModelInvocation: true} | |
| 12 | + | pins := Pins{Global: []string{"ext:b", "a", "missing"}, Project: []string{"a", "ext:b"}} | |
| 13 | + | selected, warnings := pins.Resolve([]*Skill{b, a}) | |
| 14 | + | if len(selected) != 2 || selected[0] != a || selected[1] != b { | |
| 15 | + | t.Fatalf("selected = %#v", selected) | |
| 16 | + | } | |
| 17 | + | if len(warnings) != 1 || !strings.Contains(warnings[0], "missing") { | |
| 18 | + | t.Fatalf("warnings = %v", warnings) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestPreloadPromptPreservesSkillLocationAndRequest(t *testing.T) { | |
| 23 | + | s := &Skill{Name: "review", Body: "Inspect carefully.", Path: filepath.Join("skills", "review", "SKILL.md")} | |
| 24 | + | got := PreloadPrompt([]*Skill{s}, "my request") | |
| 25 | + | for _, want := range []string{"# Skill: review", "Inspect carefully.", "Skill directory: " + filepath.Dir(s.Path), "User request:\nmy request"} { | |
| 26 | + | if !strings.Contains(got, want) { | |
| 27 | + | t.Errorf("missing %q in %q", want, got) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | if got := PreloadPrompt(nil, "unchanged"); got != "unchanged" { | |
| 31 | + | t.Fatal(got) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestPinsResolveDeduplicatesAliasAndCanonical(t *testing.T) { | |
| 36 | + | inner := &Skill{Name: "golang-patterns", Aliases: []string{"systems/subskills/golang-patterns"}, Path: "/r/systems/subskills/golang-patterns/SKILL.md"} | |
| 37 | + | other := &Skill{Name: "alpha", Path: "/r/alpha/SKILL.md"} | |
| 38 | + | pins := Pins{ | |
| 39 | + | Global: []string{"systems/subskills/golang-patterns", "alpha"}, | |
| 40 | + | Project: []string{"golang-patterns"}, | |
| 41 | + | } | |
| 42 | + | available := []*Skill{inner, other} | |
| 43 | + | for i := 0; i < 20; i++ { // map iteration must not affect the result | |
| 44 | + | selected, warnings := pins.Resolve(available) | |
| 45 | + | if len(warnings) != 0 || len(selected) != 2 || selected[0] != other || selected[1] != inner { | |
| 46 | + | t.Fatalf("selected=%v warnings=%v", selected, warnings) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | project, global := pins.Scopes(available, inner) | |
| 50 | + | if !project || !global { | |
| 51 | + | t.Fatalf("scopes = project %v global %v", project, global) | |
| 52 | + | } | |
| 53 | + | project, global = pins.Scopes(available, other) | |
| 54 | + | if project || !global { | |
| 55 | + | t.Fatalf("scopes = project %v global %v", project, global) | |
| 56 | + | } | |
| 57 | + | } | |
M packages/agent/skills/skills.go +200 -32
| @@ -23,16 +23,20 @@ | |||
| 23 | 23 | // the related ecosystems works in zut unchanged. User skill roots are | |
| 24 | 24 | // walked recursively, and nested files can also be addressed by their | |
| 25 | 25 | // slash-separated path relative to the root (for example, | |
| 26 | - | // systems-backend/subskills/golang-patterns). | |
| 26 | + | // systems-backend/subskills/golang-patterns). Symlinked directories are | |
| 27 | + | // followed; see scanUserSkills. | |
| 27 | 28 | package skills | |
| 28 | 29 | ||
| 29 | 30 | import ( | |
| 31 | + | "errors" | |
| 30 | 32 | "fmt" | |
| 31 | 33 | "io/fs" | |
| 32 | 34 | "os" | |
| 33 | 35 | "path/filepath" | |
| 36 | + | "runtime" | |
| 34 | 37 | "sort" | |
| 35 | 38 | "strings" | |
| 39 | + | "syscall" | |
| 36 | 40 | ) | |
| 37 | 41 | ||
| 38 | 42 | // Skill is one discovered SKILL.md file. | |
| @@ -237,11 +241,58 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error { | |||
| 237 | 241 | } | |
| 238 | 242 | return errs | |
| 239 | 243 | } | |
| 244 | + | // Real directories load before symlinked ones so a link cannot claim a | |
| 245 | + | // fallback name ahead of the real directory it points at. Each entry | |
| 246 | + | // stays one level deep, and loadOne still confines every resolved | |
| 247 | + | // SKILL.md to dir.Root. A directory reachable both directly and through a | |
| 248 | + | // link is loaded once, so an unnamed skill is not duplicated under its | |
| 249 | + | // alias name. | |
| 250 | + | visited := map[string]bool{} | |
| 251 | + | visit := func(path string) bool { | |
| 252 | + | resolved, err := filepath.EvalSymlinks(path) | |
| 253 | + | if err != nil { | |
| 254 | + | if !skipLinkError(err) { | |
| 255 | + | errs = append(errs, fmt.Errorf("%s: %w", path, err)) | |
| 256 | + | } | |
| 257 | + | return false | |
| 258 | + | } | |
| 259 | + | if visited[resolved] { | |
| 260 | + | return false | |
| 261 | + | } | |
| 262 | + | visited[resolved] = true | |
| 263 | + | return true | |
| 264 | + | } | |
| 265 | + | var linked []string | |
| 240 | 266 | for _, entry := range entries { | |
| 241 | - | if !entry.IsDir() { | |
| 267 | + | if entry.IsDir() { | |
| 268 | + | path := filepath.Join(dir.Dir, entry.Name()) | |
| 269 | + | if visit(path) { | |
| 270 | + | loadOne(filepath.Join(path, "SKILL.md"), entry.Name()) | |
| 271 | + | } | |
| 272 | + | continue | |
| 273 | + | } | |
| 274 | + | if entry.Type()&fs.ModeSymlink == 0 { | |
| 242 | 275 | continue | |
| 243 | 276 | } | |
| 244 | - | loadOne(filepath.Join(dir.Dir, entry.Name(), "SKILL.md"), entry.Name()) | |
| 277 | + | path := filepath.Join(dir.Dir, entry.Name()) | |
| 278 | + | info, statErr := os.Stat(path) | |
| 279 | + | if statErr != nil { | |
| 280 | + | // Dangling and cyclic links are skipped; anything else | |
| 281 | + | // (permissions, I/O) is reported. | |
| 282 | + | if !skipLinkError(statErr) { | |
| 283 | + | errs = append(errs, fmt.Errorf("%s: %w", path, statErr)) | |
| 284 | + | } | |
| 285 | + | continue | |
| 286 | + | } | |
| 287 | + | if info.IsDir() { | |
| 288 | + | linked = append(linked, entry.Name()) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | for _, name := range linked { | |
| 292 | + | path := filepath.Join(dir.Dir, name) | |
| 293 | + | if visit(path) { | |
| 294 | + | loadOne(filepath.Join(path, "SKILL.md"), name) | |
| 295 | + | } | |
| 245 | 296 | } | |
| 246 | 297 | return errs | |
| 247 | 298 | } | |
| @@ -250,49 +301,166 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error { | |||
| 250 | 301 | // populates `seen` with first-match-wins per name or alias. Split out | |
| 251 | 302 | // so Discover's includeUser=false path doesn't have to skip over a | |
| 252 | 303 | // giant block. | |
| 304 | + | // | |
| 305 | + | // Symlinked directories are followed (filepath.WalkDir would not). Real | |
| 306 | + | // directories are scanned before links so a link cannot claim a name or | |
| 307 | + | // alias ahead of the real tree, every resolved directory is scanned once so | |
| 308 | + | // link cycles terminate, and dangling or cyclic links are skipped. Skills | |
| 309 | + | // reached through a link keep the walked (link) path. | |
| 253 | 310 | func scanUserSkills(zutHome, cwd, userHome string, seen map[string]*Skill) []error { | |
| 254 | 311 | var errs []error | |
| 255 | 312 | for _, loc := range searchDirs(zutHome, cwd, userHome) { | |
| 256 | - | _ = filepath.WalkDir(loc.dir, func(path string, entry fs.DirEntry, walkErr error) error { | |
| 257 | - | if walkErr != nil { | |
| 258 | - | if !os.IsNotExist(walkErr) { | |
| 259 | - | errs = append(errs, fmt.Errorf("%s: %w", path, walkErr)) | |
| 313 | + | errs = append(errs, scanUserSkillDir(loc, seen)...) | |
| 314 | + | } | |
| 315 | + | return errs | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // Traversal bounds for one user skill location. Real skill trees are shallow | |
| 319 | + | // and small; the caps stop a pathological or mis-linked tree (for example a | |
| 320 | + | // link into a huge shared checkout) from stalling startup. Too-deep | |
| 321 | + | // subtrees are skipped and the directory-count cap ends the scan of that | |
| 322 | + | // location; either way one nonfatal diagnostic is reported and every skill | |
| 323 | + | // already found is kept. | |
| 324 | + | const ( | |
| 325 | + | maxSkillScanDepth = 64 | |
| 326 | + | maxSkillScanDirs = 10000 | |
| 327 | + | ) | |
| 328 | + | ||
| 329 | + | type linkedDir struct { | |
| 330 | + | path string | |
| 331 | + | depth int | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func scanUserSkillDir(loc location, seen map[string]*Skill) []error { | |
| 335 | + | var errs []error | |
| 336 | + | visited := map[string]bool{} | |
| 337 | + | var linkedDirs []linkedDir | |
| 338 | + | dirCount := 0 | |
| 339 | + | truncated := false // directory cap reached: stop the whole location | |
| 340 | + | reported := false | |
| 341 | + | report := func(why string) { | |
| 342 | + | if !reported { | |
| 343 | + | reported = true | |
| 344 | + | errs = append(errs, fmt.Errorf("%s: skill scan stopped early: %s; some directories were skipped", loc.dir, why)) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | // A link whose target is the location itself or one of its ancestors | |
| 348 | + | // would pull the surrounding filesystem into the scan. Compare against | |
| 349 | + | // the resolved location root. | |
| 350 | + | rootResolved, rootErr := filepath.EvalSymlinks(loc.dir) | |
| 351 | + | loadSkill := func(path string) { | |
| 352 | + | s, err := load(path, loc.label) | |
| 353 | + | if err != nil { | |
| 354 | + | if !os.IsNotExist(err) { | |
| 355 | + | errs = append(errs, fmt.Errorf("%s: %w", path, err)) | |
| 356 | + | } | |
| 357 | + | return | |
| 358 | + | } | |
| 359 | + | if s.Name == "" { | |
| 360 | + | s.Name = filepath.Base(filepath.Dir(path)) | |
| 361 | + | } | |
| 362 | + | addRelativeAlias(s, loc.dir, path) | |
| 363 | + | registerSkill(seen, s) | |
| 364 | + | } | |
| 365 | + | var walk func(dir string, depth int) | |
| 366 | + | walk = func(dir string, depth int) { | |
| 367 | + | if truncated { | |
| 368 | + | return | |
| 369 | + | } | |
| 370 | + | if depth > maxSkillScanDepth { | |
| 371 | + | report(fmt.Sprintf("nesting deeper than %d levels", maxSkillScanDepth)) | |
| 372 | + | return | |
| 373 | + | } | |
| 374 | + | resolved, err := filepath.EvalSymlinks(dir) | |
| 375 | + | if err != nil { | |
| 376 | + | if !skipLinkError(err) { | |
| 377 | + | errs = append(errs, fmt.Errorf("%s: %w", dir, err)) | |
| 378 | + | } | |
| 379 | + | return | |
| 380 | + | } | |
| 381 | + | if visited[resolved] { | |
| 382 | + | return | |
| 383 | + | } | |
| 384 | + | if dirCount >= maxSkillScanDirs { | |
| 385 | + | truncated = true | |
| 386 | + | report(fmt.Sprintf("more than %d directories", maxSkillScanDirs)) | |
| 387 | + | return | |
| 388 | + | } | |
| 389 | + | visited[resolved] = true | |
| 390 | + | dirCount++ | |
| 391 | + | entries, err := os.ReadDir(dir) | |
| 392 | + | if err != nil { | |
| 393 | + | if !os.IsNotExist(err) { | |
| 394 | + | errs = append(errs, fmt.Errorf("%s: %w", dir, err)) | |
| 395 | + | } | |
| 396 | + | return | |
| 397 | + | } | |
| 398 | + | for _, entry := range entries { | |
| 399 | + | path := filepath.Join(dir, entry.Name()) | |
| 400 | + | isDir := entry.IsDir() | |
| 401 | + | if entry.Type()&fs.ModeSymlink != 0 { | |
| 402 | + | info, statErr := os.Stat(path) | |
| 403 | + | if statErr != nil { | |
| 404 | + | // Dangling and cyclic links are not skills; SKILL.md | |
| 405 | + | // links fall through to load, which ignores ENOENT. | |
| 406 | + | if !skipLinkError(statErr) { | |
| 407 | + | errs = append(errs, fmt.Errorf("%s: %w", path, statErr)) | |
| 408 | + | } | |
| 409 | + | continue | |
| 410 | + | } | |
| 411 | + | if info.IsDir() { | |
| 412 | + | if target, err := filepath.EvalSymlinks(path); err == nil && rootErr == nil && containsPath(target, rootResolved) { | |
| 413 | + | // Link to the root or an ancestor of it: a cycle. | |
| 414 | + | continue | |
| 415 | + | } | |
| 416 | + | linkedDirs = append(linkedDirs, linkedDir{path: path, depth: depth + 1}) | |
| 417 | + | continue | |
| 260 | 418 | } | |
| 261 | - | return nil | |
| 262 | 419 | } | |
| 263 | - | if entry.IsDir() || entry.Name() != "SKILL.md" { | |
| 264 | - | return nil | |
| 420 | + | if isDir { | |
| 421 | + | walk(path, depth+1) | |
| 422 | + | continue | |
| 265 | 423 | } | |
| 266 | - | ||
| 267 | - | rel, err := filepath.Rel(loc.dir, filepath.Dir(path)) | |
| 268 | - | if err != nil { | |
| 269 | - | errs = append(errs, fmt.Errorf("%s: determine skill name: %w", path, err)) | |
| 270 | - | return nil | |
| 424 | + | if entry.Name() != "SKILL.md" { | |
| 425 | + | continue | |
| 271 | 426 | } | |
| 272 | - | if rel == "." { | |
| 427 | + | if dir == loc.dir { | |
| 273 | 428 | // Keep the existing convention that a search root is a | |
| 274 | 429 | // collection of skill directories, not a skill itself. | |
| 275 | - | return nil | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | s, err := load(path, loc.label) | |
| 279 | - | if err != nil { | |
| 280 | - | if !os.IsNotExist(err) { | |
| 281 | - | errs = append(errs, fmt.Errorf("%s: %w", path, err)) | |
| 282 | - | } | |
| 283 | - | return nil | |
| 284 | - | } | |
| 285 | - | if s.Name == "" { | |
| 286 | - | s.Name = filepath.Base(filepath.Dir(path)) | |
| 430 | + | continue | |
| 287 | 431 | } | |
| 288 | - | addRelativeAlias(s, loc.dir, path) | |
| 289 | - | registerSkill(seen, s) | |
| 290 | - | return nil | |
| 291 | - | }) | |
| 432 | + | loadSkill(path) | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | walk(loc.dir, 0) | |
| 436 | + | for i := 0; i < len(linkedDirs); i++ { | |
| 437 | + | walk(linkedDirs[i].path, linkedDirs[i].depth) | |
| 292 | 438 | } | |
| 293 | 439 | return errs | |
| 294 | 440 | } | |
| 295 | 441 | ||
| 442 | + | // containsPath reports whether child equals parent or lies beneath it. Both | |
| 443 | + | // paths must already be resolved and absolute. | |
| 444 | + | func containsPath(parent, child string) bool { | |
| 445 | + | rel, err := filepath.Rel(parent, child) | |
| 446 | + | if err != nil { | |
| 447 | + | return false | |
| 448 | + | } | |
| 449 | + | return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | // skipLinkError reports whether err means a link points at nothing or loops. | |
| 453 | + | func skipLinkError(err error) bool { | |
| 454 | + | return errors.Is(err, fs.ErrNotExist) || symlinkLoopError(err) | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func symlinkLoopError(err error) bool { | |
| 458 | + | // Windows reports cyclic links as ERROR_CANT_RESOLVE_FILENAME (1921), | |
| 459 | + | // rather than ELOOP. syscall does not export a name for this error. | |
| 460 | + | return errors.Is(err, syscall.ELOOP) || | |
| 461 | + | (runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1921))) | |
| 462 | + | } | |
| 463 | + | ||
| 296 | 464 | // addRelativeAlias makes a nested skill addressable by the directory path | |
| 297 | 465 | // used by common skill routers, for example | |
| 298 | 466 | // systems-backend/subskills/golang-patterns. | |
A packages/agent/skills/symlink_test.go +329 -0
| @@ -0,0 +1,329 @@ | |||
| 1 | + | package skills | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "runtime" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func writeSkill(t *testing.T, dir, name, desc string) { | |
| 13 | + | t.Helper() | |
| 14 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | body := "---\nname: " + name + "\ndescription: " + desc + "\n---\nbody of " + name + "\n" | |
| 18 | + | if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func symlinkOrSkip(t *testing.T, target, link string) { | |
| 24 | + | t.Helper() | |
| 25 | + | if err := os.Symlink(target, link); err != nil { | |
| 26 | + | t.Skipf("symlink unavailable: %v", err) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestDiscoverFollowsSymlinkedSkillDirectories(t *testing.T) { | |
| 31 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 32 | + | tmp := t.TempDir() | |
| 33 | + | home := filepath.Join(tmp, "home") | |
| 34 | + | shared := filepath.Join(tmp, "shared") | |
| 35 | + | writeSkill(t, filepath.Join(shared, "linked"), "linked", "from checkout") | |
| 36 | + | writeSkill(t, filepath.Join(shared, "group", "inner"), "inner", "nested") | |
| 37 | + | root := filepath.Join(home, ".agents", "skills") | |
| 38 | + | if err := os.MkdirAll(root, 0o755); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | symlinkOrSkip(t, filepath.Join(shared, "linked"), filepath.Join(root, "linked")) | |
| 42 | + | symlinkOrSkip(t, filepath.Join(shared, "group"), filepath.Join(root, "bundle")) | |
| 43 | + | symlinkOrSkip(t, filepath.Join(tmp, "missing"), filepath.Join(root, "dangling")) | |
| 44 | + | ||
| 45 | + | list, errs := Discover("", filepath.Join(tmp, "proj"), home, true) | |
| 46 | + | if len(errs) != 0 { | |
| 47 | + | t.Fatalf("errors = %v", errs) | |
| 48 | + | } | |
| 49 | + | linked := FindByName(list, "linked") | |
| 50 | + | if linked == nil { | |
| 51 | + | t.Fatalf("linked skill missing: %v", list) | |
| 52 | + | } | |
| 53 | + | // The reported path is the installed (link) location. | |
| 54 | + | if want := filepath.Join(root, "linked", "SKILL.md"); linked.Path != want { | |
| 55 | + | t.Fatalf("path = %q, want %q", linked.Path, want) | |
| 56 | + | } | |
| 57 | + | inner := FindByName(list, "inner") | |
| 58 | + | if inner == nil { | |
| 59 | + | t.Fatal("skill under linked parent missing") | |
| 60 | + | } | |
| 61 | + | // Fork slash aliases follow the walked path and keep the frontmatter name. | |
| 62 | + | if FindByName(list, "bundle/inner") != inner { | |
| 63 | + | t.Fatalf("alias bundle/inner not registered: %#v", inner.Aliases) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestDiscoverSymlinkedUserRoot(t *testing.T) { | |
| 68 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 69 | + | tmp := t.TempDir() | |
| 70 | + | real := filepath.Join(tmp, "real-skills") | |
| 71 | + | writeSkill(t, filepath.Join(real, "one"), "one", "d") | |
| 72 | + | home := filepath.Join(tmp, "home") | |
| 73 | + | if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | symlinkOrSkip(t, real, filepath.Join(home, ".claude", "skills")) | |
| 77 | + | list, errs := Discover("", filepath.Join(tmp, "proj"), home, true) | |
| 78 | + | if len(errs) != 0 || FindByName(list, "one") == nil { | |
| 79 | + | t.Fatalf("skills=%v errs=%v", list, errs) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestDiscoverSymlinkCycleTerminates(t *testing.T) { | |
| 84 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 85 | + | tmp := t.TempDir() | |
| 86 | + | home := filepath.Join(tmp, "home") | |
| 87 | + | root := filepath.Join(home, ".agents", "skills") | |
| 88 | + | writeSkill(t, filepath.Join(root, "a"), "a", "d") | |
| 89 | + | symlinkOrSkip(t, root, filepath.Join(root, "a", "loop")) | |
| 90 | + | symlinkOrSkip(t, filepath.Join(root, "self"), filepath.Join(root, "self")) // ELOOP | |
| 91 | + | list, errs := Discover("", filepath.Join(tmp, "proj"), home, true) | |
| 92 | + | if len(errs) != 0 { | |
| 93 | + | t.Fatalf("errors = %v", errs) | |
| 94 | + | } | |
| 95 | + | n := 0 | |
| 96 | + | for _, s := range list { | |
| 97 | + | if !s.Builtin { | |
| 98 | + | n++ | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | if n != 1 { | |
| 102 | + | t.Fatalf("user skills = %d, want 1: %v", n, list) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestDiscoverPrefersRealDirectoryOverLink(t *testing.T) { | |
| 107 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 108 | + | tmp := t.TempDir() | |
| 109 | + | home := filepath.Join(tmp, "home") | |
| 110 | + | root := filepath.Join(home, ".agents", "skills") | |
| 111 | + | // "aaa" sorts before "real" but is a link to it; the real path must win. | |
| 112 | + | writeSkill(t, filepath.Join(root, "real"), "real", "d") | |
| 113 | + | symlinkOrSkip(t, filepath.Join(root, "real"), filepath.Join(root, "aaa")) | |
| 114 | + | list, errs := Discover("", filepath.Join(tmp, "proj"), home, true) | |
| 115 | + | if len(errs) != 0 { | |
| 116 | + | t.Fatalf("errors = %v", errs) | |
| 117 | + | } | |
| 118 | + | s := FindByName(list, "real") | |
| 119 | + | if s == nil || s.Path != filepath.Join(root, "real", "SKILL.md") { | |
| 120 | + | t.Fatalf("skill = %#v", s) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestBundledSymlinkedEntriesStayConfinedToRoot(t *testing.T) { | |
| 125 | + | tmp := t.TempDir() | |
| 126 | + | root := filepath.Join(tmp, "ext") | |
| 127 | + | bundle := filepath.Join(root, "skills") | |
| 128 | + | writeSkill(t, filepath.Join(root, "shared", "inside"), "inside", "in root") | |
| 129 | + | writeSkill(t, filepath.Join(tmp, "outside", "evil"), "evil", "outside root") | |
| 130 | + | if err := os.MkdirAll(bundle, 0o755); err != nil { | |
| 131 | + | t.Fatal(err) | |
| 132 | + | } | |
| 133 | + | symlinkOrSkip(t, filepath.Join(root, "shared", "inside"), filepath.Join(bundle, "inside")) | |
| 134 | + | symlinkOrSkip(t, filepath.Join(tmp, "outside", "evil"), filepath.Join(bundle, "evil")) | |
| 135 | + | symlinkOrSkip(t, filepath.Join(tmp, "nowhere"), filepath.Join(bundle, "dangling")) | |
| 136 | + | ||
| 137 | + | got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}}) | |
| 138 | + | if FindByName(got, "inside") == nil { | |
| 139 | + | t.Fatalf("in-root link not followed: %v (errs %v)", got, errs) | |
| 140 | + | } | |
| 141 | + | if FindByName(got, "evil") != nil { | |
| 142 | + | t.Fatal("link escaped extension root") | |
| 143 | + | } | |
| 144 | + | if len(errs) == 0 { | |
| 145 | + | t.Fatal("expected escape diagnostic") | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestBundledSymlinkCycleAndDanglingAreSilentButRealDirsWin(t *testing.T) { | |
| 150 | + | tmp := t.TempDir() | |
| 151 | + | root := filepath.Join(tmp, "ext") | |
| 152 | + | bundle := filepath.Join(root, "skills") | |
| 153 | + | // "aaa" sorts before "zzz" and links to it. Both carry the same | |
| 154 | + | // frontmatter name, so whichever loads first owns it: the real directory. | |
| 155 | + | realDir := filepath.Join(bundle, "zzz") | |
| 156 | + | writeSkill(t, realDir, "shared", "real") | |
| 157 | + | symlinkOrSkip(t, realDir, filepath.Join(bundle, "aaa")) | |
| 158 | + | symlinkOrSkip(t, filepath.Join(bundle, "loop"), filepath.Join(bundle, "loop")) | |
| 159 | + | symlinkOrSkip(t, filepath.Join(tmp, "nowhere"), filepath.Join(bundle, "dangling")) | |
| 160 | + | got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}}) | |
| 161 | + | if len(errs) != 0 { | |
| 162 | + | t.Fatalf("dangling/cyclic links must be skipped silently: %v", errs) | |
| 163 | + | } | |
| 164 | + | s := FindByName(got, "shared") | |
| 165 | + | if s == nil || s.Path != filepath.Join(realDir, "SKILL.md") { | |
| 166 | + | t.Fatalf("real directory did not win: %#v", s) | |
| 167 | + | } | |
| 168 | + | if len(got) != 1 { | |
| 169 | + | t.Fatalf("skills = %v", got) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestBundledSymlinkPermissionErrorIsReported(t *testing.T) { | |
| 174 | + | if runtime.GOOS == "windows" || os.Geteuid() == 0 { | |
| 175 | + | t.Skip("permission bits are not enforced here") | |
| 176 | + | } | |
| 177 | + | tmp := t.TempDir() | |
| 178 | + | root := filepath.Join(tmp, "ext") | |
| 179 | + | bundle := filepath.Join(root, "skills") | |
| 180 | + | locked := filepath.Join(root, "locked") | |
| 181 | + | writeSkill(t, filepath.Join(locked, "hidden"), "hidden", "d") | |
| 182 | + | if err := os.MkdirAll(bundle, 0o755); err != nil { | |
| 183 | + | t.Fatal(err) | |
| 184 | + | } | |
| 185 | + | symlinkOrSkip(t, filepath.Join(locked, "hidden"), filepath.Join(bundle, "hidden")) | |
| 186 | + | if err := os.Chmod(locked, 0); err != nil { | |
| 187 | + | t.Fatal(err) | |
| 188 | + | } | |
| 189 | + | t.Cleanup(func() { os.Chmod(locked, 0o755) }) | |
| 190 | + | got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}}) | |
| 191 | + | if len(got) != 0 || len(errs) == 0 { | |
| 192 | + | t.Fatalf("permission failure was swallowed: skills=%v errs=%v", got, errs) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestDiscoverReportsUnreadableLinkTarget(t *testing.T) { | |
| 197 | + | if runtime.GOOS == "windows" || os.Geteuid() == 0 { | |
| 198 | + | t.Skip("permission bits are not enforced here") | |
| 199 | + | } | |
| 200 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 201 | + | tmp := t.TempDir() | |
| 202 | + | home := filepath.Join(tmp, "home") | |
| 203 | + | root := filepath.Join(home, ".agents", "skills") | |
| 204 | + | locked := filepath.Join(tmp, "locked") | |
| 205 | + | writeSkill(t, filepath.Join(locked, "x"), "x", "d") | |
| 206 | + | if err := os.MkdirAll(root, 0o755); err != nil { | |
| 207 | + | t.Fatal(err) | |
| 208 | + | } | |
| 209 | + | symlinkOrSkip(t, filepath.Join(locked, "x"), filepath.Join(root, "x")) | |
| 210 | + | if err := os.Chmod(locked, 0); err != nil { | |
| 211 | + | t.Fatal(err) | |
| 212 | + | } | |
| 213 | + | t.Cleanup(func() { os.Chmod(locked, 0o755) }) | |
| 214 | + | _, errs := Discover("", filepath.Join(tmp, "proj"), home, true) | |
| 215 | + | if len(errs) == 0 { | |
| 216 | + | t.Fatal("permission failure on a linked directory was swallowed") | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func userSkillCount(list []*Skill) int { | |
| 221 | + | n := 0 | |
| 222 | + | for _, s := range list { | |
| 223 | + | if !s.Builtin { | |
| 224 | + | n++ | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | return n | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestDiscoverIgnoresLinkToAncestorOfRoot(t *testing.T) { | |
| 231 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 232 | + | tmp := t.TempDir() | |
| 233 | + | home := filepath.Join(tmp, "home") | |
| 234 | + | root := filepath.Join(home, ".agents", "skills") | |
| 235 | + | writeSkill(t, filepath.Join(root, "mine"), "mine", "d") | |
| 236 | + | // A sibling tree that must never be pulled in through an ancestor link. | |
| 237 | + | writeSkill(t, filepath.Join(home, "unrelated", "stray"), "stray", "d") | |
| 238 | + | symlinkOrSkip(t, home, filepath.Join(root, "up-home")) | |
| 239 | + | symlinkOrSkip(t, filepath.Join(home, ".agents"), filepath.Join(root, "up-agents")) | |
| 240 | + | symlinkOrSkip(t, root, filepath.Join(root, "self")) | |
| 241 | + | // On Windows a bare separator depends on the process drive, which can | |
| 242 | + | // differ from the temporary directory's drive. Use this tree's own root. | |
| 243 | + | symlinkOrSkip(t, filepath.VolumeName(root)+string(filepath.Separator), filepath.Join(root, "up-fs-root")) | |
| 244 | + | list, errs := Discover("", filepath.Join(tmp, "proj"), home, true) | |
| 245 | + | if len(errs) != 0 { | |
| 246 | + | t.Fatalf("errors = %v", errs) | |
| 247 | + | } | |
| 248 | + | if FindByName(list, "mine") == nil || FindByName(list, "stray") != nil || userSkillCount(list) != 1 { | |
| 249 | + | t.Fatalf("skills = %v", list) | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestDiscoverKeepsExternalSharedLinkThatDoesNotContainRoot(t *testing.T) { | |
| 254 | + | t.Setenv("ZUT_AGENT_SKILLS", "") | |
| 255 | + | tmp := t.TempDir() | |
| 256 | + | home := filepath.Join(tmp, "home") | |
| 257 | + | root := filepath.Join(home, ".agents", "skills") | |
| 258 | + | shared := filepath.Join(tmp, "dev", "skills-checkout") | |
| 259 | + | writeSkill(t, filepath.Join(shared, "team", "review"), "review", "d") | |
| 260 | + | if err := os.MkdirAll(root, 0o755); err != nil { | |
| 261 | + | t.Fatal(err) | |
| 262 | + | } | |
| 263 | + | symlinkOrSkip(t, shared, filepath.Join(root, "shared")) | |
| 264 | + | list, errs := Discover("", filepath.Join(tmp, "proj"), home, true) | |
| 265 | + | if len(errs) != 0 || FindByName(list, "review") == nil { | |
| 266 | + | t.Fatalf("skills=%v errs=%v", list, errs) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestScanUserSkillDirBoundsDepthWithDiagnostic(t *testing.T) { | |
| 271 | + | tmp := t.TempDir() | |
| 272 | + | root := filepath.Join(tmp, "skills") | |
| 273 | + | writeSkill(t, filepath.Join(root, "top"), "top", "d") | |
| 274 | + | deep := root | |
| 275 | + | for i := 0; i < maxSkillScanDepth+5; i++ { | |
| 276 | + | deep = filepath.Join(deep, "d") | |
| 277 | + | } | |
| 278 | + | writeSkill(t, deep, "too-deep", "d") | |
| 279 | + | seen := map[string]*Skill{} | |
| 280 | + | errs := scanUserSkillDir(location{dir: root, label: "test"}, seen) | |
| 281 | + | if seen["top"] == nil || seen["too-deep"] != nil { | |
| 282 | + | t.Fatalf("seen = %v", seen) | |
| 283 | + | } | |
| 284 | + | if len(errs) != 1 || !strings.Contains(errs[0].Error(), "skill scan stopped early") { | |
| 285 | + | t.Fatalf("errs = %v", errs) | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func TestScanUserSkillDirBoundsDirectoryCount(t *testing.T) { | |
| 290 | + | tmp := t.TempDir() | |
| 291 | + | root := filepath.Join(tmp, "skills") | |
| 292 | + | writeSkill(t, filepath.Join(root, "a-first"), "first", "d") | |
| 293 | + | for i := 0; i < maxSkillScanDirs+50; i++ { | |
| 294 | + | if err := os.MkdirAll(filepath.Join(root, "z-bulk", fmt.Sprintf("d%05d", i)), 0o755); err != nil { | |
| 295 | + | t.Fatal(err) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | seen := map[string]*Skill{} | |
| 299 | + | errs := scanUserSkillDir(location{dir: root, label: "test"}, seen) | |
| 300 | + | if seen["first"] == nil { | |
| 301 | + | t.Fatal("skills found before the cap were dropped") | |
| 302 | + | } | |
| 303 | + | if len(errs) != 1 || !strings.Contains(errs[0].Error(), "more than") { | |
| 304 | + | t.Fatalf("errs = %v", errs) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestBundledRealAndAliasUnnamedSkillLoadsOnce(t *testing.T) { | |
| 309 | + | tmp := t.TempDir() | |
| 310 | + | root := filepath.Join(tmp, "ext") | |
| 311 | + | bundle := filepath.Join(root, "skills") | |
| 312 | + | bar := filepath.Join(bundle, "bar") | |
| 313 | + | if err := os.MkdirAll(bar, 0o755); err != nil { | |
| 314 | + | t.Fatal(err) | |
| 315 | + | } | |
| 316 | + | // No frontmatter name: the directory name is the fallback. | |
| 317 | + | if err := os.WriteFile(filepath.Join(bar, "SKILL.md"), []byte("---\ndescription: d\n---\nbody\n"), 0o644); err != nil { | |
| 318 | + | t.Fatal(err) | |
| 319 | + | } | |
| 320 | + | symlinkOrSkip(t, bar, filepath.Join(bundle, "foo")) // sorts after "bar" only by luck; also test the reverse | |
| 321 | + | symlinkOrSkip(t, bar, filepath.Join(bundle, "aaa")) | |
| 322 | + | got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}}) | |
| 323 | + | if len(errs) != 0 { | |
| 324 | + | t.Fatalf("errs = %v", errs) | |
| 325 | + | } | |
| 326 | + | if len(got) != 1 || got[0].Name != "bar" || FindByName(got, "foo") != nil || FindByName(got, "aaa") != nil { | |
| 327 | + | t.Fatalf("alias duplicated the skill: %v", got) | |
| 328 | + | } | |
| 329 | + | } | |
M packages/agent/subagent_factory.go +4 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "strings" | |
| 8 | 8 | "sync" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/zut/packages/agent/extensions" | |
| 10 | 11 | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | 12 | "github.com/bnema/zut/packages/agent/tools" | |
| 12 | 13 | "github.com/bnema/zut/packages/core" | |
| @@ -351,6 +352,9 @@ func residentChildRegistry(catalogue core.Registry, names []string) (core.Regist | |||
| 351 | 352 | if !ok { | |
| 352 | 353 | return nil, fmt.Errorf("resident child declares unavailable tool %q", name) | |
| 353 | 354 | } | |
| 355 | + | if extensions.IsInteractiveTool(tool) { | |
| 356 | + | return nil, fmt.Errorf("resident child tool %q is interactive and not allowed", name) | |
| 357 | + | } | |
| 354 | 358 | registry[name] = tool | |
| 355 | 359 | } | |
| 356 | 360 | return registry, nil | |
M packages/agent/subagent_runtime.go +4 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "sync" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | + | "github.com/bnema/zut/packages/agent/extensions" | |
| 13 | 14 | "github.com/bnema/zut/packages/agent/subagents" | |
| 14 | 15 | "github.com/bnema/zut/packages/agent/tools" | |
| 15 | 16 | "github.com/bnema/zut/packages/core" | |
| @@ -357,6 +358,9 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 357 | 358 | // scheduling/extension tools are never inherited. | |
| 358 | 359 | allTools := make([]string, 0, len(catalogue)) | |
| 359 | 360 | for name := range catalogue { | |
| 361 | + | if extensions.IsInteractiveTool(catalogue[name]) { | |
| 362 | + | continue | |
| 363 | + | } | |
| 360 | 364 | switch name { | |
| 361 | 365 | case tools.SubagentToolName, "update_goal", tools.PlanToolName: | |
| 362 | 366 | continue | |
A packages/core/session_repeated_tool_id_test.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRepairToolUseResultPairsPreservesRepeatedIDsAcrossTurns(t *testing.T) { | |
| 11 | + | messages := []provider.Message{ | |
| 12 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "reused", Name: "read"}}}, | |
| 13 | + | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "reused", Content: []provider.Content{provider.TextBlock{Text: "first"}}}}}, | |
| 14 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "reused", Name: "read"}}}, | |
| 15 | + | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "reused", Content: []provider.Content{provider.TextBlock{Text: "second"}}}}}, | |
| 16 | + | } | |
| 17 | + | repaired := repairToolUseResultPairs(messages) | |
| 18 | + | if !reflect.DeepEqual(repaired, messages) { | |
| 19 | + | t.Fatal("session repair changed valid repeated-ID pairs") | |
| 20 | + | } | |
| 21 | + | if wire := provider.RepairOrphanedToolResults(repaired); !reflect.DeepEqual(wire, messages) { | |
| 22 | + | t.Fatal("provider repair dropped a valid pair after session repair") | |
| 23 | + | } | |
| 24 | + | } | |
M packages/provider/amazon_bedrock.go +12 -6
| @@ -225,14 +225,19 @@ type bedrockRequest struct { | |||
| 225 | 225 | } `json:"toolConfig,omitempty"` | |
| 226 | 226 | } | |
| 227 | 227 | ||
| 228 | + | // normalizeBedrockToolResults places each tool result directly after the | |
| 229 | + | // assistant turn that issued its call. Providers can reuse a tool ID across | |
| 230 | + | // turns, so results are queued per ID in transcript order and consumed by | |
| 231 | + | // successive calls. Orphaned or excess results are dropped first, using the | |
| 232 | + | // same chronological pairing as every other provider request builder, so a | |
| 233 | + | // stray early result cannot shift onto a later call that reuses its ID. | |
| 228 | 234 | func normalizeBedrockToolResults(msgs []Message) []Message { | |
| 229 | - | resultByID := map[string]ToolResultBlock{} | |
| 235 | + | msgs = RepairOrphanedToolResults(msgs) | |
| 236 | + | resultsByID := map[string][]ToolResultBlock{} | |
| 230 | 237 | for _, m := range msgs { | |
| 231 | 238 | for _, c := range m.Content { | |
| 232 | 239 | if tr, ok := c.(ToolResultBlock); ok { | |
| 233 | - | if _, exists := resultByID[tr.CallID]; !exists { | |
| 234 | - | resultByID[tr.CallID] = tr | |
| 235 | - | } | |
| 240 | + | resultsByID[tr.CallID] = append(resultsByID[tr.CallID], tr) | |
| 236 | 241 | } | |
| 237 | 242 | } | |
| 238 | 243 | } | |
| @@ -265,8 +270,9 @@ func normalizeBedrockToolResults(msgs []Message) []Message { | |||
| 265 | 270 | ||
| 266 | 271 | results := make([]Content, 0, len(toolCalls)) | |
| 267 | 272 | for _, tc := range toolCalls { | |
| 268 | - | if tr, ok := resultByID[tc.ID]; ok { | |
| 269 | - | results = append(results, tr) | |
| 273 | + | if queue := resultsByID[tc.ID]; len(queue) > 0 { | |
| 274 | + | results = append(results, queue[0]) | |
| 275 | + | resultsByID[tc.ID] = queue[1:] | |
| 270 | 276 | continue | |
| 271 | 277 | } | |
| 272 | 278 | results = append(results, ToolResultBlock{ | |
M packages/provider/amazon_bedrock_test.go +51 -0
| @@ -162,6 +162,57 @@ func TestNormalizeBedrockToolResultsInjectsMissingResult(t *testing.T) { | |||
| 162 | 162 | } | |
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | + | func bedrockResultText(t *testing.T, m Message) string { | |
| 166 | + | t.Helper() | |
| 167 | + | if m.Role != RoleTool || len(m.Content) != 1 { | |
| 168 | + | t.Fatalf("message = %+v, want one tool result", m) | |
| 169 | + | } | |
| 170 | + | tr, ok := m.Content[0].(ToolResultBlock) | |
| 171 | + | if !ok || len(tr.Content) != 1 { | |
| 172 | + | t.Fatalf("content = %#v, want ToolResultBlock", m.Content[0]) | |
| 173 | + | } | |
| 174 | + | return tr.Content[0].(TextBlock).Text | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func TestNormalizeBedrockToolResultsPairsReusedIDsInOrder(t *testing.T) { | |
| 178 | + | call := Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read", Arguments: json.RawMessage(`{}`)}}} | |
| 179 | + | result := func(text string) Message { | |
| 180 | + | return Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: text}}}}} | |
| 181 | + | } | |
| 182 | + | out := normalizeBedrockToolResults([]Message{call, result("first"), call, result("second")}) | |
| 183 | + | if len(out) != 4 { | |
| 184 | + | t.Fatalf("got %d messages, want 4: %+v", len(out), out) | |
| 185 | + | } | |
| 186 | + | if got := bedrockResultText(t, out[1]); got != "first" { | |
| 187 | + | t.Fatalf("first result = %q", got) | |
| 188 | + | } | |
| 189 | + | if got := bedrockResultText(t, out[3]); got != "second" { | |
| 190 | + | t.Fatalf("second result = %q", got) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestNormalizeBedrockToolResultsDropsLeadingOrphanBeforeReusedID(t *testing.T) { | |
| 195 | + | call := Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read", Arguments: json.RawMessage(`{}`)}}} | |
| 196 | + | result := func(text string) Message { | |
| 197 | + | return Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: text}}}}} | |
| 198 | + | } | |
| 199 | + | // The leading and excess results have no matching call at their position; | |
| 200 | + | // they must not be consumed by the later calls that reuse the ID. | |
| 201 | + | out := normalizeBedrockToolResults([]Message{ | |
| 202 | + | result("orphan"), call, result("first"), result("excess"), call, | |
| 203 | + | }) | |
| 204 | + | if len(out) != 4 { | |
| 205 | + | t.Fatalf("got %d messages, want 4: %+v", len(out), out) | |
| 206 | + | } | |
| 207 | + | if got := bedrockResultText(t, out[1]); got != "first" { | |
| 208 | + | t.Fatalf("first result = %q, want first", got) | |
| 209 | + | } | |
| 210 | + | tr := out[3].Content[0].(ToolResultBlock) | |
| 211 | + | if out[3].Role != RoleTool || tr.CallID != "reused" || !tr.IsError { | |
| 212 | + | t.Fatalf("second call must get a synthetic error result, got %+v", out[3]) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 165 | 216 | func TestBedrockModelSupportsCaching(t *testing.T) { | |
| 166 | 217 | cases := []struct { | |
| 167 | 218 | model string | |
M packages/provider/anthropic.go +3 -1
| @@ -607,7 +607,9 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event | |||
| 607 | 607 | // Remove x-api-key entirely by NOT setting it. | |
| 608 | 608 | } else { | |
| 609 | 609 | httpReq.Header.Set("accept", "text/event-stream") | |
| 610 | - | httpReq.Header.Set("x-api-key", c.apiKey) | |
| 610 | + | if c.apiKey != "" { | |
| 611 | + | httpReq.Header.Set("x-api-key", c.apiKey) | |
| 612 | + | } | |
| 611 | 613 | } | |
| 612 | 614 | // Extra headers (set by anthropic-messages-compatible third parties | |
| 613 | 615 | // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.). | |
M packages/provider/auth/auth_test.go +300 -0
| @@ -2,8 +2,10 @@ package auth | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "net" | |
| 6 | 7 | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 7 | 9 | "net/url" | |
| 8 | 10 | "path/filepath" | |
| 9 | 11 | "strings" | |
| @@ -293,3 +295,301 @@ func TestServerCallbackError(t *testing.T) { | |||
| 293 | 295 | t.Fatal("no result") | |
| 294 | 296 | } | |
| 295 | 297 | } | |
| 298 | + | ||
| 299 | + | func TestStartManualOAuthRejectsOpenAIWithoutCallback(t *testing.T) { | |
| 300 | + | m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |
| 301 | + | if _, err := m.StartManualOAuth("openai-codex"); err == nil { | |
| 302 | + | t.Fatal("expected manual OpenAI OAuth to be rejected") | |
| 303 | + | } else if !strings.Contains(err.Error(), "manual code login is not supported") { | |
| 304 | + | t.Fatalf("unexpected error: %v", err) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestBrowserOAuthSharesTransactionWithPastedCode(t *testing.T) { | |
| 309 | + | for _, provider := range []string{"anthropic", "openai-codex"} { | |
| 310 | + | t.Run(provider, func(t *testing.T) { | |
| 311 | + | m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |
| 312 | + | m.openBrowser = false | |
| 313 | + | t.Cleanup(m.Close) | |
| 314 | + | loginURL, err := m.StartOAuth(provider) | |
| 315 | + | if err != nil { | |
| 316 | + | if strings.Contains(err.Error(), "bind ") { | |
| 317 | + | t.Skipf("callback port unavailable: %v", err) | |
| 318 | + | } | |
| 319 | + | t.Fatal(err) | |
| 320 | + | } | |
| 321 | + | u, err := url.Parse(loginURL) | |
| 322 | + | if err != nil { | |
| 323 | + | t.Fatal(err) | |
| 324 | + | } | |
| 325 | + | state := u.Query().Get("state") | |
| 326 | + | if m.manualOp == nil || m.manualState != state || m.manualPKCE.Challenge != u.Query().Get("code_challenge") { | |
| 327 | + | t.Fatal("pasted-code path does not share the browser transaction") | |
| 328 | + | } | |
| 329 | + | if err := m.CompleteManualOAuth(context.Background(), "code#wrong-state"); err == nil || !strings.Contains(err.Error(), "state mismatch") { | |
| 330 | + | t.Fatalf("wrong state accepted: %v", err) | |
| 331 | + | } | |
| 332 | + | if err := m.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" { | |
| 333 | + | t.Fatalf("shared transaction unavailable after bad code: %v", err) | |
| 334 | + | } | |
| 335 | + | m.CancelOAuth() | |
| 336 | + | if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") { | |
| 337 | + | t.Fatalf("canceled flow still accepts code: %v", err) | |
| 338 | + | } | |
| 339 | + | }) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func TestPastedOAuthCodeUsesBrowserPKCE(t *testing.T) { | |
| 344 | + | requests := make(chan url.Values, 2) | |
| 345 | + | tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 346 | + | _ = r.ParseForm() | |
| 347 | + | requests <- r.PostForm | |
| 348 | + | w.Header().Set("content-type", "application/json") | |
| 349 | + | _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`)) | |
| 350 | + | })) | |
| 351 | + | defer tokenServer.Close() | |
| 352 | + | old := OpenAIOAuth.TokenURL | |
| 353 | + | OpenAIOAuth.TokenURL = tokenServer.URL | |
| 354 | + | t.Cleanup(func() { OpenAIOAuth.TokenURL = old }) | |
| 355 | + | ||
| 356 | + | for _, method := range []string{"paste", "callback"} { | |
| 357 | + | t.Run(method, func(t *testing.T) { | |
| 358 | + | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | |
| 359 | + | m := NewManager(store) | |
| 360 | + | m.openBrowser = false | |
| 361 | + | t.Cleanup(m.Close) | |
| 362 | + | loginURL, err := m.StartOAuth("openai-codex") | |
| 363 | + | if err != nil { | |
| 364 | + | if strings.Contains(err.Error(), "bind ") { | |
| 365 | + | t.Skipf("callback port unavailable: %v", err) | |
| 366 | + | } | |
| 367 | + | t.Fatal(err) | |
| 368 | + | } | |
| 369 | + | u, err := url.Parse(loginURL) | |
| 370 | + | if err != nil { | |
| 371 | + | t.Fatal(err) | |
| 372 | + | } | |
| 373 | + | verifier := m.manualPKCE.Verifier | |
| 374 | + | redirect := u.Query().Get("redirect_uri") | |
| 375 | + | codeURL := redirect + "?code=test-code&state=" + url.QueryEscape(u.Query().Get("state")) | |
| 376 | + | if method == "paste" { | |
| 377 | + | if err := m.CompleteManualOAuth(context.Background(), codeURL); err != nil { | |
| 378 | + | t.Fatal(err) | |
| 379 | + | } | |
| 380 | + | } else { | |
| 381 | + | resp, err := http.Get(codeURL) | |
| 382 | + | if err != nil { | |
| 383 | + | t.Fatal(err) | |
| 384 | + | } | |
| 385 | + | resp.Body.Close() | |
| 386 | + | if resp.StatusCode != http.StatusOK { | |
| 387 | + | t.Fatalf("callback status = %d", resp.StatusCode) | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | select { | |
| 391 | + | case ev := <-m.Events(): | |
| 392 | + | if ev.Kind != "started" { | |
| 393 | + | t.Fatalf("event = %+v", ev) | |
| 394 | + | } | |
| 395 | + | case <-time.After(2 * time.Second): | |
| 396 | + | t.Fatal("no started event") | |
| 397 | + | } | |
| 398 | + | select { | |
| 399 | + | case ev := <-m.Events(): | |
| 400 | + | if ev.Kind != "success" || ev.Provider != "openai-codex" { | |
| 401 | + | t.Fatalf("event = %+v", ev) | |
| 402 | + | } | |
| 403 | + | case <-time.After(2 * time.Second): | |
| 404 | + | t.Fatal("no success event") | |
| 405 | + | } | |
| 406 | + | select { | |
| 407 | + | case form := <-requests: | |
| 408 | + | if form.Get("code_verifier") != verifier || form.Get("redirect_uri") != redirect || form.Get("code") != "test-code" { | |
| 409 | + | t.Fatalf("exchange did not use browser transaction: %v", form) | |
| 410 | + | } | |
| 411 | + | case <-time.After(2 * time.Second): | |
| 412 | + | t.Fatal("no token exchange") | |
| 413 | + | } | |
| 414 | + | creds, err := store.Load() | |
| 415 | + | if err != nil { | |
| 416 | + | t.Fatal(err) | |
| 417 | + | } | |
| 418 | + | if creds.OpenAI.OAuth == nil || creds.OpenAI.OAuth.AccessToken != "test-token" { | |
| 419 | + | t.Fatalf("stored credential: %v", creds.OpenAI.OAuth) | |
| 420 | + | } | |
| 421 | + | }) | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func TestCanceledBrowserOAuthDoesNotExchangeOrEmit(t *testing.T) { | |
| 426 | + | exchanged := make(chan struct{}, 1) | |
| 427 | + | tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 428 | + | exchanged <- struct{}{} | |
| 429 | + | w.Header().Set("content-type", "application/json") | |
| 430 | + | _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`)) | |
| 431 | + | })) | |
| 432 | + | defer tokenServer.Close() | |
| 433 | + | old := OpenAIOAuth.TokenURL | |
| 434 | + | OpenAIOAuth.TokenURL = tokenServer.URL | |
| 435 | + | t.Cleanup(func() { OpenAIOAuth.TokenURL = old }) | |
| 436 | + | ||
| 437 | + | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | |
| 438 | + | m := NewManager(store) | |
| 439 | + | m.openBrowser = false | |
| 440 | + | t.Cleanup(m.Close) | |
| 441 | + | loginURL, err := m.StartOAuth("openai-codex") | |
| 442 | + | if err != nil { | |
| 443 | + | if strings.Contains(err.Error(), "bind ") { | |
| 444 | + | t.Skipf("callback port unavailable: %v", err) | |
| 445 | + | } | |
| 446 | + | t.Fatal(err) | |
| 447 | + | } | |
| 448 | + | u, _ := url.Parse(loginURL) | |
| 449 | + | state := u.Query().Get("state") | |
| 450 | + | <-m.Events() // started | |
| 451 | + | m.CancelOAuth() | |
| 452 | + | // A pasted code after cancellation must not resurrect the transaction. | |
| 453 | + | if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil { | |
| 454 | + | t.Fatal("canceled transaction accepted a pasted code") | |
| 455 | + | } | |
| 456 | + | select { | |
| 457 | + | case <-exchanged: | |
| 458 | + | t.Fatal("token exchange ran after cancellation") | |
| 459 | + | case ev := <-m.Events(): | |
| 460 | + | t.Fatalf("unexpected event after cancellation: %+v", ev) | |
| 461 | + | case <-time.After(100 * time.Millisecond): | |
| 462 | + | } | |
| 463 | + | // The callback port must be released so a new login can bind it. | |
| 464 | + | if _, err := m.StartOAuth("openai-codex"); err != nil { | |
| 465 | + | t.Fatalf("callback port not released after cancel: %v", err) | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | func TestPastedOAuthCodeConsumesTransactionOnce(t *testing.T) { | |
| 470 | + | tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 471 | + | w.Header().Set("content-type", "application/json") | |
| 472 | + | _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`)) | |
| 473 | + | })) | |
| 474 | + | defer tokenServer.Close() | |
| 475 | + | old := OpenAIOAuth.TokenURL | |
| 476 | + | OpenAIOAuth.TokenURL = tokenServer.URL | |
| 477 | + | t.Cleanup(func() { OpenAIOAuth.TokenURL = old }) | |
| 478 | + | ||
| 479 | + | m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |
| 480 | + | m.openBrowser = false | |
| 481 | + | t.Cleanup(m.Close) | |
| 482 | + | loginURL, err := m.StartOAuth("openai-codex") | |
| 483 | + | if err != nil { | |
| 484 | + | if strings.Contains(err.Error(), "bind ") { | |
| 485 | + | t.Skipf("callback port unavailable: %v", err) | |
| 486 | + | } | |
| 487 | + | t.Fatal(err) | |
| 488 | + | } | |
| 489 | + | u, _ := url.Parse(loginURL) | |
| 490 | + | state := u.Query().Get("state") | |
| 491 | + | if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err != nil { | |
| 492 | + | t.Fatal(err) | |
| 493 | + | } | |
| 494 | + | if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil { | |
| 495 | + | t.Fatal("second pasted code accepted") | |
| 496 | + | } | |
| 497 | + | var success int | |
| 498 | + | deadline := time.After(300 * time.Millisecond) | |
| 499 | + | loop: | |
| 500 | + | for { | |
| 501 | + | select { | |
| 502 | + | case ev := <-m.Events(): | |
| 503 | + | if ev.Kind == "success" { | |
| 504 | + | success++ | |
| 505 | + | } | |
| 506 | + | case <-deadline: | |
| 507 | + | break loop | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | if success != 1 { | |
| 511 | + | t.Fatalf("success events = %d, want 1", success) | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | // A pasted-code exchange that is blocked at the token endpoint must be | |
| 516 | + | // canceled by CancelOAuth, Close, or a newer login: no credential, no success | |
| 517 | + | // event. | |
| 518 | + | func TestCancelAbortsInFlightPastedCodeExchange(t *testing.T) { | |
| 519 | + | for _, name := range []string{"cancel", "new login", "close"} { | |
| 520 | + | t.Run(name, func(t *testing.T) { | |
| 521 | + | arrived := make(chan struct{}, 4) | |
| 522 | + | release := make(chan struct{}) | |
| 523 | + | tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 524 | + | arrived <- struct{}{} | |
| 525 | + | select { | |
| 526 | + | case <-release: | |
| 527 | + | case <-r.Context().Done(): | |
| 528 | + | return | |
| 529 | + | } | |
| 530 | + | w.Header().Set("content-type", "application/json") | |
| 531 | + | _, _ = w.Write([]byte(`{"access_token":"late-token","refresh_token":"late-refresh","expires_in":3600}`)) | |
| 532 | + | })) | |
| 533 | + | defer tokenServer.Close() | |
| 534 | + | defer close(release) | |
| 535 | + | old := AnthropicManualOAuth.TokenURL | |
| 536 | + | AnthropicManualOAuth.TokenURL = tokenServer.URL | |
| 537 | + | t.Cleanup(func() { AnthropicManualOAuth.TokenURL = old }) | |
| 538 | + | ||
| 539 | + | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | |
| 540 | + | m := NewManager(store) | |
| 541 | + | m.openBrowser = false | |
| 542 | + | t.Cleanup(m.Close) | |
| 543 | + | loginURL, err := m.StartManualOAuth("anthropic") | |
| 544 | + | if err != nil { | |
| 545 | + | t.Fatal(err) | |
| 546 | + | } | |
| 547 | + | u, _ := url.Parse(loginURL) | |
| 548 | + | state := u.Query().Get("state") | |
| 549 | + | <-m.Events() // started | |
| 550 | + | ||
| 551 | + | // The caller's context is never canceled: only the manager may | |
| 552 | + | // abort the exchange. | |
| 553 | + | done := make(chan error, 1) | |
| 554 | + | go func() { done <- m.CompleteManualOAuth(context.Background(), "code#"+state) }() | |
| 555 | + | select { | |
| 556 | + | case <-arrived: | |
| 557 | + | case <-time.After(2 * time.Second): | |
| 558 | + | t.Fatal("token exchange never started") | |
| 559 | + | } | |
| 560 | + | switch name { | |
| 561 | + | case "new login": | |
| 562 | + | if _, err := m.StartManualOAuth("anthropic"); err != nil { | |
| 563 | + | t.Fatal(err) | |
| 564 | + | } | |
| 565 | + | case "close": | |
| 566 | + | m.Close() | |
| 567 | + | default: | |
| 568 | + | m.CancelOAuth() | |
| 569 | + | } | |
| 570 | + | select { | |
| 571 | + | case err := <-done: | |
| 572 | + | if !errors.Is(err, ErrOAuthCanceled) { | |
| 573 | + | t.Fatalf("CompleteManualOAuth = %v, want ErrOAuthCanceled", err) | |
| 574 | + | } | |
| 575 | + | case <-time.After(2 * time.Second): | |
| 576 | + | t.Fatal("canceled exchange did not return") | |
| 577 | + | } | |
| 578 | + | creds, err := store.Load() | |
| 579 | + | if err == nil && creds.Anthropic.OAuth != nil { | |
| 580 | + | t.Fatalf("canceled exchange stored a credential") | |
| 581 | + | } | |
| 582 | + | for { | |
| 583 | + | select { | |
| 584 | + | case ev := <-m.Events(): | |
| 585 | + | if ev.Kind == "success" || ev.Kind == "error" { | |
| 586 | + | t.Fatalf("stale event after cancel: %+v", ev) | |
| 587 | + | } | |
| 588 | + | continue | |
| 589 | + | default: | |
| 590 | + | } | |
| 591 | + | break | |
| 592 | + | } | |
| 593 | + | }) | |
| 594 | + | } | |
| 595 | + | } | |
M packages/provider/auth/manager.go +111 -44
| @@ -40,6 +40,12 @@ type Manager struct { | |||
| 40 | 40 | manualEventProvider string | |
| 41 | 41 | manualPKCE PKCE | |
| 42 | 42 | manualState string | |
| 43 | + | ||
| 44 | + | // exchangeCtx/exchangeCancel own the in-flight pasted-code token exchange. | |
| 45 | + | // They are registered under mu so CancelOAuth, Close and a newer login can | |
| 46 | + | // abort it, and the exchange re-checks them before persisting anything. | |
| 47 | + | exchangeCtx context.Context | |
| 48 | + | exchangeCancel context.CancelFunc | |
| 43 | 49 | } | |
| 44 | 50 | ||
| 45 | 51 | // NewManager returns a Manager bound to store. | |
| @@ -67,14 +73,8 @@ func (m *Manager) Close() { | |||
| 67 | 73 | cancel() | |
| 68 | 74 | m.keyServer = nil | |
| 69 | 75 | } | |
| 70 | - | if m.oauthServer != nil { | |
| 71 | - | m.oauthServer.Shutdown() | |
| 72 | - | m.oauthServer = nil | |
| 73 | - | } | |
| 74 | - | if m.oauthCancel != nil { | |
| 75 | - | m.oauthCancel() | |
| 76 | - | m.oauthCancel = nil | |
| 77 | - | } | |
| 76 | + | // stopOAuthLocked also cancels an in-flight pasted-code exchange. | |
| 77 | + | m.stopOAuthLocked() | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | 80 | // ---- API key flow ---- | |
| @@ -159,13 +159,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | 161 | m.mu.Lock() | |
| 162 | - | if m.oauthServer != nil { | |
| 163 | - | m.oauthServer.Shutdown() | |
| 164 | - | m.oauthServer = nil | |
| 165 | - | } | |
| 166 | - | if m.oauthCancel != nil { | |
| 167 | - | m.oauthCancel() | |
| 168 | - | } | |
| 162 | + | m.stopOAuthLocked() | |
| 169 | 163 | m.mu.Unlock() | |
| 170 | 164 | ||
| 171 | 165 | pkce, err := NewPKCE() | |
| @@ -187,6 +181,14 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 187 | 181 | m.oauthServer = cs | |
| 188 | 182 | m.oauthCtx = ctx | |
| 189 | 183 | m.oauthCancel = cancel | |
| 184 | + | // The pasted-code path shares the browser transaction (same PKCE | |
| 185 | + | // verifier, state, and redirect URI) so a code copied from either | |
| 186 | + | // route is exchanged exactly once. | |
| 187 | + | m.manualOp = &op | |
| 188 | + | m.manualStoreProvider = storeProvider | |
| 189 | + | m.manualEventProvider = provider | |
| 190 | + | m.manualPKCE = pkce | |
| 191 | + | m.manualState = state | |
| 190 | 192 | m.mu.Unlock() | |
| 191 | 193 | ||
| 192 | 194 | go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state) | |
| @@ -203,18 +205,49 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvide | |||
| 203 | 205 | res, err := cs.Result(waitCtx) | |
| 204 | 206 | if err != nil { | |
| 205 | 207 | if ctx.Err() == nil { | |
| 208 | + | m.mu.Lock() | |
| 209 | + | if m.oauthCtx == ctx { | |
| 210 | + | m.clearManualOAuthLocked() | |
| 211 | + | } | |
| 212 | + | m.mu.Unlock() | |
| 206 | 213 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"}) | |
| 207 | 214 | } | |
| 208 | 215 | return | |
| 209 | 216 | } | |
| 217 | + | if ctx.Err() != nil { | |
| 218 | + | return | |
| 219 | + | } | |
| 210 | 220 | if res.Err != nil { | |
| 221 | + | m.mu.Lock() | |
| 222 | + | if m.oauthCtx == ctx { | |
| 223 | + | m.clearManualOAuthLocked() | |
| 224 | + | } | |
| 225 | + | m.mu.Unlock() | |
| 211 | 226 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()}) | |
| 212 | 227 | return | |
| 213 | 228 | } | |
| 229 | + | // Claim the transaction. If a pasted code, cancellation, or a newer | |
| 230 | + | // login already consumed it, this callback result is stale. | |
| 231 | + | m.mu.Lock() | |
| 232 | + | if m.oauthCtx != ctx || m.manualOp == nil || ctx.Err() != nil { | |
| 233 | + | m.mu.Unlock() | |
| 234 | + | return | |
| 235 | + | } | |
| 236 | + | m.clearManualOAuthLocked() | |
| 237 | + | m.mu.Unlock() | |
| 214 | 238 | ||
| 215 | - | exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 239 | + | // Derived from ctx so canceling the login also aborts the exchange. | |
| 240 | + | exCtx, exCancel := context.WithTimeout(ctx, 30*time.Second) | |
| 216 | 241 | defer exCancel() | |
| 217 | 242 | tok, err := op.Exchange(exCtx, res.Code, res.State, pkce) | |
| 243 | + | // Persist and report only while this login is still the current one. The | |
| 244 | + | // check and the write share the lock, so a cancel or newer login cannot | |
| 245 | + | // interleave between them. | |
| 246 | + | m.mu.Lock() | |
| 247 | + | defer m.mu.Unlock() | |
| 248 | + | if ctx.Err() != nil || m.oauthCtx != ctx { | |
| 249 | + | return | |
| 250 | + | } | |
| 218 | 251 | if err != nil { | |
| 219 | 252 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | |
| 220 | 253 | return | |
| @@ -230,9 +263,7 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvide | |||
| 230 | 263 | func (m *Manager) StartKimiDeviceOAuth() (string, error) { | |
| 231 | 264 | ctx, cancel := context.WithCancel(context.Background()) | |
| 232 | 265 | m.mu.Lock() | |
| 233 | - | if m.oauthCancel != nil { | |
| 234 | - | m.oauthCancel() | |
| 235 | - | } | |
| 266 | + | m.stopOAuthLocked() | |
| 236 | 267 | m.oauthCtx = ctx | |
| 237 | 268 | m.oauthCancel = cancel | |
| 238 | 269 | m.mu.Unlock() | |
| @@ -265,9 +296,7 @@ func (m *Manager) StartKimiDeviceOAuth() (string, error) { | |||
| 265 | 296 | func (m *Manager) StartXAIDeviceOAuth() (string, error) { | |
| 266 | 297 | ctx, cancel := context.WithCancel(context.Background()) | |
| 267 | 298 | m.mu.Lock() | |
| 268 | - | if m.oauthCancel != nil { | |
| 269 | - | m.oauthCancel() | |
| 270 | - | } | |
| 299 | + | m.stopOAuthLocked() | |
| 271 | 300 | m.oauthCtx = ctx | |
| 272 | 301 | m.oauthCancel = cancel | |
| 273 | 302 | m.mu.Unlock() | |
| @@ -300,9 +329,7 @@ func (m *Manager) StartXAIDeviceOAuth() (string, error) { | |||
| 300 | 329 | func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) { | |
| 301 | 330 | ctx, cancel := context.WithCancel(context.Background()) | |
| 302 | 331 | m.mu.Lock() | |
| 303 | - | if m.oauthCancel != nil { | |
| 304 | - | m.oauthCancel() | |
| 305 | - | } | |
| 332 | + | m.stopOAuthLocked() | |
| 306 | 333 | m.oauthCtx = ctx | |
| 307 | 334 | m.oauthCancel = cancel | |
| 308 | 335 | m.mu.Unlock() | |
| @@ -354,8 +381,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 354 | 381 | case "anthropic": | |
| 355 | 382 | op = AnthropicManualOAuth | |
| 356 | 383 | case "openai", "openai-codex": | |
| 357 | - | op = OpenAIOAuth | |
| 358 | - | storeProvider = "openai" | |
| 384 | + | return "", fmt.Errorf("openai login requires the browser callback flow; manual code login is not supported") | |
| 359 | 385 | case "google": | |
| 360 | 386 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | |
| 361 | 387 | case "deepseek": | |
| @@ -364,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 364 | 390 | return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google") | |
| 365 | 391 | } | |
| 366 | 392 | ||
| 393 | + | m.CancelOAuth() | |
| 367 | 394 | pkce, err := NewPKCE() | |
| 368 | 395 | if err != nil { | |
| 369 | 396 | return "", err | |
| @@ -389,27 +416,50 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 389 | 416 | // a token and stores it. Accepts either a raw code or a "code#state" | |
| 390 | 417 | // token shown by providers like Anthropic when code=true is set. | |
| 391 | 418 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |
| 419 | + | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | |
| 392 | 420 | m.mu.Lock() | |
| 393 | - | op := m.manualOp | |
| 394 | - | storeProvider := m.manualStoreProvider | |
| 395 | - | eventProvider := m.manualEventProvider | |
| 396 | - | pkce := m.manualPKCE | |
| 397 | - | state := m.manualState | |
| 398 | - | m.mu.Unlock() | |
| 399 | - | if op == nil { | |
| 421 | + | if m.manualOp == nil { | |
| 422 | + | m.mu.Unlock() | |
| 400 | 423 | return fmt.Errorf("no manual oauth flow in progress") | |
| 401 | 424 | } | |
| 402 | - | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | |
| 403 | - | if pastedState != "" { | |
| 404 | - | state = pastedState | |
| 425 | + | if pastedState != "" && pastedState != m.manualState { | |
| 426 | + | m.mu.Unlock() | |
| 427 | + | return fmt.Errorf("oauth state mismatch") | |
| 405 | 428 | } | |
| 406 | 429 | if code == "" { | |
| 430 | + | m.mu.Unlock() | |
| 407 | 431 | return fmt.Errorf("empty code") | |
| 408 | 432 | } | |
| 433 | + | // Claim the transaction and stop the browser callback so only one | |
| 434 | + | // result can be produced. | |
| 435 | + | op := *m.manualOp | |
| 436 | + | storeProvider := m.manualStoreProvider | |
| 437 | + | eventProvider := m.manualEventProvider | |
| 438 | + | pkce := m.manualPKCE | |
| 439 | + | state := m.manualState | |
| 440 | + | m.stopOAuthLocked() | |
| 441 | + | // The exchange is manager-owned: derived from the caller's context but | |
| 442 | + | // cancelable through CancelOAuth, Close, or a newer login. | |
| 409 | 443 | exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) | |
| 444 | + | m.exchangeCtx, m.exchangeCancel = exCtx, cancel | |
| 445 | + | m.mu.Unlock() | |
| 410 | 446 | defer cancel() | |
| 447 | + | ||
| 411 | 448 | tok, err := op.Exchange(exCtx, code, state, pkce) | |
| 449 | + | m.mu.Lock() | |
| 450 | + | defer m.mu.Unlock() | |
| 451 | + | if m.exchangeCtx != exCtx || exCtx.Err() != nil && err == nil { | |
| 452 | + | // Canceled or superseded: report the cancellation, store nothing, | |
| 453 | + | // emit nothing. | |
| 454 | + | return ErrOAuthCanceled | |
| 455 | + | } | |
| 456 | + | if m.exchangeCtx == exCtx { | |
| 457 | + | m.exchangeCtx, m.exchangeCancel = nil, nil | |
| 458 | + | } | |
| 412 | 459 | if err != nil { | |
| 460 | + | if errors.Is(err, context.Canceled) { | |
| 461 | + | return ErrOAuthCanceled | |
| 462 | + | } | |
| 413 | 463 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | |
| 414 | 464 | return err | |
| 415 | 465 | } | |
| @@ -417,17 +467,14 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |||
| 417 | 467 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | |
| 418 | 468 | return err | |
| 419 | 469 | } | |
| 420 | - | m.mu.Lock() | |
| 421 | - | m.manualOp = nil | |
| 422 | - | m.manualStoreProvider = "" | |
| 423 | - | m.manualEventProvider = "" | |
| 424 | - | m.manualPKCE = PKCE{} | |
| 425 | - | m.manualState = "" | |
| 426 | - | m.mu.Unlock() | |
| 427 | 470 | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) | |
| 428 | 471 | return nil | |
| 429 | 472 | } | |
| 430 | 473 | ||
| 474 | + | // ErrOAuthCanceled is returned to a pasted-code caller whose login was | |
| 475 | + | // canceled or replaced while its token exchange was in flight. | |
| 476 | + | var ErrOAuthCanceled = errors.New("oauth login canceled") | |
| 477 | + | ||
| 431 | 478 | // parseManualCodeInput accepts any of: | |
| 432 | 479 | // - a bare authorization code | |
| 433 | 480 | // - a "code#state" pair | |
| @@ -483,6 +530,17 @@ func HasBrowser() bool { | |||
| 483 | 530 | func (m *Manager) CancelOAuth() { | |
| 484 | 531 | m.mu.Lock() | |
| 485 | 532 | defer m.mu.Unlock() | |
| 533 | + | m.stopOAuthLocked() | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // stopOAuthLocked cancels any in-flight OAuth flow, shuts down its callback | |
| 537 | + | // server, and discards the shared transaction. m.mu must be held. | |
| 538 | + | func (m *Manager) stopOAuthLocked() { | |
| 539 | + | if m.exchangeCancel != nil { | |
| 540 | + | m.exchangeCancel() | |
| 541 | + | m.exchangeCancel = nil | |
| 542 | + | m.exchangeCtx = nil | |
| 543 | + | } | |
| 486 | 544 | if m.oauthCancel != nil { | |
| 487 | 545 | m.oauthCancel() | |
| 488 | 546 | m.oauthCancel = nil | |
| @@ -491,6 +549,15 @@ func (m *Manager) CancelOAuth() { | |||
| 491 | 549 | m.oauthServer.Shutdown() | |
| 492 | 550 | m.oauthServer = nil | |
| 493 | 551 | } | |
| 552 | + | m.clearManualOAuthLocked() | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | func (m *Manager) clearManualOAuthLocked() { | |
| 556 | + | m.manualOp = nil | |
| 557 | + | m.manualStoreProvider = "" | |
| 558 | + | m.manualEventProvider = "" | |
| 559 | + | m.manualPKCE = PKCE{} | |
| 560 | + | m.manualState = "" | |
| 494 | 561 | } | |
| 495 | 562 | ||
| 496 | 563 | // ---- shared ---- | |
M packages/provider/catalog_builtin.go +40 -13
| @@ -14,7 +14,14 @@ package provider | |||
| 14 | 14 | // the small seed list for kimi / deepseek / google) are not duplicated | |
| 15 | 15 | // here; they take precedence on (provider, id) match. | |
| 16 | 16 | ||
| 17 | - | func init() { Catalog = append(Catalog, builtinCatalog...) } | |
| 17 | + | func init() { | |
| 18 | + | for i := range builtinCatalog { | |
| 19 | + | if builtinCatalog[i].Provider == ProviderGitHubCopilot { | |
| 20 | + | configureCopilotModel(&builtinCatalog[i]) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | Catalog = append(Catalog, builtinCatalog...) | |
| 24 | + | } | |
| 18 | 25 | ||
| 19 | 26 | var builtinCatalog = []Model{ | |
| 20 | 27 | // ----- amazon-bedrock ----- | |
| @@ -141,28 +148,48 @@ var builtinCatalog = []Model{ | |||
| 141 | 148 | {Provider: "fireworks", ID: "accounts/fireworks/routers/glm-5p1-fast", DisplayName: "GLM 5.1 Fast", ContextWindow: 202800, MaxOutput: 131072, Reasoning: true, PriceInput: 2.8, PriceOutput: 8.8, PriceCacheRead: 0.52, BaseURL: "https://api.fireworks.ai/inference"}, | |
| 142 | 149 | {Provider: "fireworks", ID: "accounts/fireworks/routers/kimi-k2p6-turbo", DisplayName: "Kimi K2.6 Turbo", ContextWindow: 262000, MaxOutput: 262000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.3, BaseURL: "https://api.fireworks.ai/inference"}, | |
| 143 | 150 | // ----- github-copilot ----- | |
| 151 | + | // Copilot model metadata follows the upstream catalog; availability is | |
| 152 | + | // filtered at runtime using the account's inference model list. | |
| 153 | + | {Provider: "github-copilot", ID: "claude-fable-5", DisplayName: "Claude Fable 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 154 | + | {Provider: "github-copilot", ID: "claude-fable-5.1", DisplayName: "Claude Fable 5.1", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.25, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 155 | + | {Provider: "github-copilot", ID: "claude-opus-4.8-fast", DisplayName: "Claude Opus 4.8 (fast mode)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 156 | + | {Provider: "github-copilot", ID: "claude-opus-5", DisplayName: "Claude Opus 5", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, AdaptiveThinking: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 157 | + | {Provider: "github-copilot", ID: "gemini-3.6-flash", DisplayName: "Gemini 3.6 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 158 | + | {Provider: "github-copilot", ID: "gemini-3.7-flash", DisplayName: "Gemini 3.7 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 159 | + | {Provider: "github-copilot", ID: "gemini-3.8-flash", DisplayName: "Gemini 3.8 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 160 | + | {Provider: "github-copilot", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.5, PriceTierInputTokens: 200000, PriceInputAbove: 4, PriceOutputAbove: 12, PriceCacheReadAbove: 1, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 161 | + | {Provider: "github-copilot", ID: "grok-4.6", DisplayName: "Grok 4.6", ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.5, PriceTierInputTokens: 200000, PriceInputAbove: 4, PriceOutputAbove: 12, PriceCacheReadAbove: 1, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 162 | + | {Provider: "github-copilot", ID: "kimi-k2.7-code", DisplayName: "Kimi K2.7 Code", ContextWindow: 256000, MaxOutput: 32000, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.19, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 163 | + | {Provider: "github-copilot", ID: "kimi-k3", DisplayName: "Kimi K3", ContextWindow: 1048576, MaxOutput: 131072, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 164 | + | {Provider: "github-copilot", ID: "mai-code-1.1-flash", DisplayName: "MAI-Code-1.1-Flash", ContextWindow: 256000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 144 | 165 | {Provider: "github-copilot", ID: "claude-haiku-4.5", DisplayName: "Claude Haiku 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 145 | - | {Provider: "github-copilot", ID: "claude-opus-4.5", DisplayName: "Claude Opus 4.5", ContextWindow: 160000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 146 | - | {Provider: "github-copilot", ID: "claude-opus-4.6", DisplayName: "Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 147 | 166 | {Provider: "github-copilot", ID: "claude-opus-4.7", DisplayName: "Claude Opus 4.7", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 148 | 167 | {Provider: "github-copilot", ID: "claude-opus-4.8", DisplayName: "Claude Opus 4.8", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 149 | - | {Provider: "github-copilot", ID: "claude-sonnet-4.5", DisplayName: "Claude Sonnet 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 150 | - | {Provider: "github-copilot", ID: "claude-sonnet-4.6", DisplayName: "Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 151 | 168 | {Provider: "github-copilot", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 152 | - | {Provider: "github-copilot", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro", ContextWindow: 128000, MaxOutput: 64000, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 153 | - | {Provider: "github-copilot", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 154 | - | {Provider: "github-copilot", ID: "gemini-3.1-pro-preview", DisplayName: "Gemini 3.1 Pro Preview", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 155 | 169 | {Provider: "github-copilot", ID: "gemini-3.5-flash", DisplayName: "Gemini 3.5 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 156 | - | {Provider: "github-copilot", ID: "gpt-4.1", DisplayName: "GPT-4.1", ContextWindow: 128000, MaxOutput: 16384, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 157 | - | {Provider: "github-copilot", ID: "gpt-4o", DisplayName: "GPT-4o", ContextWindow: 128000, MaxOutput: 4096, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 158 | 170 | {Provider: "github-copilot", ID: "gpt-5-mini", DisplayName: "GPT-5-mini", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 159 | - | {Provider: "github-copilot", ID: "gpt-5.2", DisplayName: "GPT-5.2", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 160 | - | {Provider: "github-copilot", ID: "gpt-5.2-codex", DisplayName: "GPT-5.2-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 161 | 171 | {Provider: "github-copilot", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 162 | 172 | {Provider: "github-copilot", ID: "gpt-5.4", DisplayName: "GPT-5.4", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 163 | 173 | {Provider: "github-copilot", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 Mini", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 164 | 174 | {Provider: "github-copilot", ID: "gpt-5.5", DisplayName: "GPT-5.5", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 165 | - | {Provider: "github-copilot", ID: "grok-code-fast-1", DisplayName: "Grok Code Fast 1", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 175 | + | {Provider: "github-copilot", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 176 | + | {Provider: "github-copilot", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 177 | + | {Provider: "github-copilot", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 178 | + | {Provider: "github-copilot", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, PriceTierInputTokens: 272000, PriceInputAbove: 20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 179 | + | // Availability varies by account; keep metadata for explicit selections | |
| 180 | + | // and existing sessions even when a model is hidden from the picker. | |
| 181 | + | {Provider: "github-copilot", ID: "claude-opus-4.5", DisplayName: "Claude Opus 4.5", ContextWindow: 160000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 182 | + | {Provider: "github-copilot", ID: "claude-opus-4.6", DisplayName: "Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 183 | + | {Provider: "github-copilot", ID: "claude-sonnet-4.5", DisplayName: "Claude Sonnet 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 184 | + | {Provider: "github-copilot", ID: "claude-sonnet-4.6", DisplayName: "Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 185 | + | {Provider: "github-copilot", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro", ContextWindow: 128000, MaxOutput: 64000, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 186 | + | {Provider: "github-copilot", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 187 | + | {Provider: "github-copilot", ID: "gemini-3.1-pro-preview", DisplayName: "Gemini 3.1 Pro Preview", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 188 | + | {Provider: "github-copilot", ID: "gpt-4.1", API: APICompletions, DisplayName: "GPT-4.1", ContextWindow: 128000, MaxOutput: 16384, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 189 | + | {Provider: "github-copilot", ID: "gpt-4o", API: APICompletions, DisplayName: "GPT-4o", ContextWindow: 128000, MaxOutput: 4096, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 190 | + | {Provider: "github-copilot", ID: "gpt-5.2", DisplayName: "GPT-5.2", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 191 | + | {Provider: "github-copilot", ID: "gpt-5.2-codex", DisplayName: "GPT-5.2-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 192 | + | {Provider: "github-copilot", ID: "grok-code-fast-1", API: APICompletions, DisplayName: "Grok Code Fast 1", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"}, | |
| 166 | 193 | // ----- google ----- | |
| 167 | 194 | {Provider: "google", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, BaseURL: "https://generativelanguage.googleapis.com/v1beta"}, | |
| 168 | 195 | {Provider: "google", ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 2, PriceOutput: 12, PriceCacheRead: 0.2, BaseURL: "https://generativelanguage.googleapis.com/v1beta"}, | |
A packages/provider/copilot_catalog_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestCopilotCatalogCoversPublicChatModels(t *testing.T) { | |
| 10 | + | groups := map[string][]string{ | |
| 11 | + | APIAnthropicMessages: {"claude-fable-5", "claude-fable-5.1", "claude-haiku-4.5", "claude-opus-4.7", "claude-opus-4.8", "claude-opus-4.8-fast", "claude-opus-5", "claude-sonnet-5"}, | |
| 12 | + | APIResponses: {"gpt-5-mini", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-astra", "grok-4.5", "grok-4.6", "mai-code-1.1-flash"}, | |
| 13 | + | APICompletions: {"gemini-3.5-flash", "gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash", "kimi-k2.7-code", "kimi-k3"}, | |
| 14 | + | } | |
| 15 | + | for api, ids := range groups { | |
| 16 | + | for _, id := range ids { | |
| 17 | + | t.Run(id, func(t *testing.T) { | |
| 18 | + | m, err := FindModel("github-copilot", id) | |
| 19 | + | if err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | if m.API != api { | |
| 23 | + | t.Fatalf("API = %q, want %q", m.API, api) | |
| 24 | + | } | |
| 25 | + | if m.ContextWindow <= 0 || m.MaxOutput <= 0 || m.MaxOutput >= m.ContextWindow { | |
| 26 | + | t.Fatalf("invalid context/output limits: %d/%d", m.ContextWindow, m.MaxOutput) | |
| 27 | + | } | |
| 28 | + | if m.BaseURL != copilotDefaultBaseURL || !m.Reasoning { | |
| 29 | + | t.Fatal("invalid endpoint or reasoning metadata") | |
| 30 | + | } | |
| 31 | + | client := NewGithubCopilotClient("synthetic-token").(*copilotClient) | |
| 32 | + | captures := make(map[string]*routeCaptureClient) | |
| 33 | + | for _, key := range []string{APIAnthropicMessages, APIResponses, APICompletions} { | |
| 34 | + | capture := &routeCaptureClient{name: "github-copilot"} | |
| 35 | + | captures[key] = capture | |
| 36 | + | client.router.byAPI[key] = capture | |
| 37 | + | } | |
| 38 | + | stream, err := client.Stream(context.Background(), Request{Model: id}) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | for range stream { | |
| 43 | + | } | |
| 44 | + | for key, capture := range captures { | |
| 45 | + | want := 0 | |
| 46 | + | if key == api { | |
| 47 | + | want = 1 | |
| 48 | + | } | |
| 49 | + | if len(capture.models) != want { | |
| 50 | + | t.Errorf("%s routed to %s incorrectly", id, key) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestCopilotCatalogKeepsPreviouslySupportedIDsAndExcludesUtilityModels(t *testing.T) { | |
| 59 | + | // Rows that shipped before the catalog refresh stay selectable until | |
| 60 | + | // retirement is verified; account availability hides unavailable ones. | |
| 61 | + | kept := map[string]string{ | |
| 62 | + | "claude-opus-4.5": APIAnthropicMessages, "claude-opus-4.6": APIAnthropicMessages, | |
| 63 | + | "claude-sonnet-4.5": APIAnthropicMessages, "claude-sonnet-4.6": APIAnthropicMessages, | |
| 64 | + | "gemini-2.5-pro": APICompletions, "gemini-3-flash-preview": APICompletions, "gemini-3.1-pro-preview": APICompletions, | |
| 65 | + | "gpt-4.1": APICompletions, "gpt-4o": APICompletions, "grok-code-fast-1": APICompletions, | |
| 66 | + | "gpt-5.2": APIResponses, "gpt-5.2-codex": APIResponses, | |
| 67 | + | } | |
| 68 | + | for id, api := range kept { | |
| 69 | + | m, err := FindModel("github-copilot", id) | |
| 70 | + | if err != nil { | |
| 71 | + | t.Errorf("previously supported Copilot model %q dropped: %v", id, err) | |
| 72 | + | continue | |
| 73 | + | } | |
| 74 | + | if m.API != api { | |
| 75 | + | t.Errorf("%s API = %q, want %q", id, m.API, api) | |
| 76 | + | } | |
| 77 | + | if (strings.HasSuffix(id, "-4.5") || strings.HasSuffix(id, "-4.6")) && (m.AdaptiveThinking || usesAdaptiveThinking(m)) { | |
| 78 | + | t.Errorf("%s must not use adaptive thinking", id) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | // GPT-5.4 nano is a Codex VS Code model, not a Copilot Chat choice. | |
| 82 | + | if _, err := FindModel("github-copilot", "gpt-5.4-nano"); err == nil { | |
| 83 | + | t.Error("Copilot catalog includes gpt-5.4-nano") | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestConfigureCopilotModelAdaptiveDefaultsFollowKnownFamilies(t *testing.T) { | |
| 88 | + | for id, want := range map[string]bool{ | |
| 89 | + | "claude-opus-4.6": false, "claude-sonnet-4.6": false, "claude-sonnet-4.5": false, "claude-haiku-4.5": false, | |
| 90 | + | "claude-opus-4.7": true, "claude-opus-4.8": true, "claude-opus-5": true, "claude-sonnet-5": true, "claude-fable-5.1": true, | |
| 91 | + | } { | |
| 92 | + | m := Model{Provider: ProviderGitHubCopilot, ID: id} | |
| 93 | + | configureCopilotModel(&m) | |
| 94 | + | if m.API != APIAnthropicMessages || m.AdaptiveThinking != want { | |
| 95 | + | t.Errorf("%s API=%q adaptive=%v, want adaptive=%v", id, m.API, m.AdaptiveThinking, want) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | // Explicit metadata for a 4.6 row is preserved. | |
| 99 | + | m := Model{Provider: ProviderGitHubCopilot, ID: "claude-opus-4.6", AdaptiveThinking: true} | |
| 100 | + | configureCopilotModel(&m) | |
| 101 | + | if !m.AdaptiveThinking { | |
| 102 | + | t.Error("explicit adaptive metadata was cleared") | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestCopilotCatalogOmitsRetiredMAIPickerRow(t *testing.T) { | |
| 107 | + | if _, err := FindModel("github-copilot", "mai-code-1-flash-picker"); err == nil { | |
| 108 | + | t.Error("retired mai-code-1-flash-picker row is still in the catalog") | |
| 109 | + | } | |
| 110 | + | } | |
A packages/provider/discover_copilot.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // DiscoverCopilotAvailableModels lists the account's selectable tool-capable | |
| 12 | + | // models. It does not enable model policies or persist account-specific data. | |
| 13 | + | func DiscoverCopilotAvailableModels(ctx context.Context, pat string) ([]string, error) { | |
| 14 | + | token, err := copilotCache.get(ctx, pat) | |
| 15 | + | if err != nil { | |
| 16 | + | return nil, err | |
| 17 | + | } | |
| 18 | + | baseURL := token.baseURL | |
| 19 | + | if baseURL == "" { | |
| 20 | + | baseURL = copilotDefaultBaseURL | |
| 21 | + | } | |
| 22 | + | client := &http.Client{Transport: &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}} | |
| 23 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil) | |
| 24 | + | if err != nil { | |
| 25 | + | return nil, err | |
| 26 | + | } | |
| 27 | + | req.Header.Set("Accept", "application/json") | |
| 28 | + | req.Header.Set("X-GitHub-Api-Version", "2026-06-01") | |
| 29 | + | resp, err := client.Do(req) | |
| 30 | + | if err != nil { | |
| 31 | + | return nil, fmt.Errorf("copilot model discovery: %w", err) | |
| 32 | + | } | |
| 33 | + | defer resp.Body.Close() | |
| 34 | + | if resp.StatusCode != http.StatusOK { | |
| 35 | + | return nil, fmt.Errorf("copilot model discovery: HTTP %d", resp.StatusCode) | |
| 36 | + | } | |
| 37 | + | body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) | |
| 38 | + | if err != nil { | |
| 39 | + | return nil, fmt.Errorf("copilot model discovery: %w", err) | |
| 40 | + | } | |
| 41 | + | // The fallback is deliberately restricted to the individual endpoint. | |
| 42 | + | individual := baseURL == copilotDefaultBaseURL | |
| 43 | + | return parseCopilotAvailableModels(body, individual) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func parseCopilotAvailableModels(body []byte, allowPolicyFallback bool) ([]string, error) { | |
| 47 | + | var catalog struct { | |
| 48 | + | Data []struct { | |
| 49 | + | ID string `json:"id"` | |
| 50 | + | Picker bool `json:"model_picker_enabled"` | |
| 51 | + | Policy struct { | |
| 52 | + | State string `json:"state"` | |
| 53 | + | } `json:"policy"` | |
| 54 | + | Capabilities struct { | |
| 55 | + | Supports struct { | |
| 56 | + | ToolCalls *bool `json:"tool_calls"` | |
| 57 | + | } `json:"supports"` | |
| 58 | + | } `json:"capabilities"` | |
| 59 | + | } `json:"data"` | |
| 60 | + | } | |
| 61 | + | if err := json.Unmarshal(body, &catalog); err != nil || catalog.Data == nil { | |
| 62 | + | return nil, fmt.Errorf("copilot model discovery: invalid models response") | |
| 63 | + | } | |
| 64 | + | picker := make([]string, 0) | |
| 65 | + | enabled := make([]string, 0) | |
| 66 | + | seen := make(map[string]bool) | |
| 67 | + | for _, m := range catalog.Data { | |
| 68 | + | if m.ID == "" || seen[m.ID] || (m.Capabilities.Supports.ToolCalls != nil && !*m.Capabilities.Supports.ToolCalls) { | |
| 69 | + | continue | |
| 70 | + | } | |
| 71 | + | seen[m.ID] = true | |
| 72 | + | if m.Picker && m.Policy.State != "disabled" { | |
| 73 | + | picker = append(picker, m.ID) | |
| 74 | + | } | |
| 75 | + | if m.Policy.State == "enabled" { | |
| 76 | + | enabled = append(enabled, m.ID) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | if len(picker) == 0 && allowPolicyFallback { | |
| 80 | + | return enabled, nil | |
| 81 | + | } | |
| 82 | + | return picker, nil | |
| 83 | + | } | |
A packages/provider/discover_copilot_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "reflect" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestParseCopilotAvailableModels(t *testing.T) { | |
| 14 | + | for _, tc := range []struct { | |
| 15 | + | name, body string | |
| 16 | + | fallback bool | |
| 17 | + | want []string | |
| 18 | + | invalid bool | |
| 19 | + | }{ | |
| 20 | + | {"picker", `{"data":[{"id":"yes","model_picker_enabled":true},{"id":"disabled","model_picker_enabled":true,"policy":{"state":"disabled"}},{"id":"hidden","policy":{"state":"enabled"}},{"id":"no-tools","model_picker_enabled":true,"capabilities":{"supports":{"tool_calls":false}}}]}`, true, []string{"yes"}, false}, | |
| 21 | + | {"individual fallback", `{"data":[{"id":"enabled","policy":{"state":"enabled"}},{"id":"unconfigured","policy":{"state":"unconfigured"}},{"id":"disabled","policy":{"state":"disabled"}}]}`, true, []string{"enabled"}, false}, | |
| 22 | + | {"enterprise no fallback", `{"data":[{"id":"enabled","policy":{"state":"enabled"}}]}`, false, []string{}, false}, | |
| 23 | + | {"empty", `{"data":[]}`, true, []string{}, false}, | |
| 24 | + | {"duplicate", `{"data":[{"id":"yes","model_picker_enabled":true},{"id":"yes","model_picker_enabled":true}]}`, false, []string{"yes"}, false}, | |
| 25 | + | {"missing", `{}`, false, nil, true}, | |
| 26 | + | {"null", `{"data":null}`, false, nil, true}, | |
| 27 | + | {"invalid", `{"data":{}}`, false, nil, true}, | |
| 28 | + | } { | |
| 29 | + | t.Run(tc.name, func(t *testing.T) { | |
| 30 | + | got, err := parseCopilotAvailableModels([]byte(tc.body), tc.fallback) | |
| 31 | + | if (err != nil) != tc.invalid { | |
| 32 | + | t.Fatalf("error = %v", err) | |
| 33 | + | } | |
| 34 | + | if !tc.invalid && !reflect.DeepEqual(got, tc.want) { | |
| 35 | + | t.Fatalf("got %v, want %v", got, tc.want) | |
| 36 | + | } | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestCopilotDiscoveryHTTP(t *testing.T) { | |
| 42 | + | for _, status := range []int{200, 403, 429} { | |
| 43 | + | t.Run(fmt.Sprint(status), func(t *testing.T) { | |
| 44 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 45 | + | if r.Method != "GET" || r.URL.Path != "/models" { | |
| 46 | + | t.Error("incorrect discovery request") | |
| 47 | + | } | |
| 48 | + | if r.Header.Get("Authorization") != "Bearer synthetic-session-token" || r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-GitHub-Api-Version") != "2026-06-01" { | |
| 49 | + | t.Error("incorrect discovery headers") | |
| 50 | + | } | |
| 51 | + | w.WriteHeader(status) | |
| 52 | + | if status != 200 { | |
| 53 | + | fmt.Fprint(w, "sensitive-response-body") | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | fmt.Fprint(w, `{"data":[{"id":"claude-sonnet-5","model_picker_enabled":true}]}`) | |
| 57 | + | })) | |
| 58 | + | defer srv.Close() | |
| 59 | + | pat := seedCopilotTestToken(t, srv.URL) | |
| 60 | + | got, err := DiscoverCopilotAvailableModels(context.Background(), pat) | |
| 61 | + | if status == 200 { | |
| 62 | + | if err != nil || !reflect.DeepEqual(got, []string{"claude-sonnet-5"}) { | |
| 63 | + | t.Fatalf("got %v, %v", got, err) | |
| 64 | + | } | |
| 65 | + | } else if err == nil || strings.Contains(err.Error(), "sensitive-response-body") { | |
| 66 | + | t.Fatalf("unexpected error %v", err) | |
| 67 | + | } | |
| 68 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 69 | + | cancel() | |
| 70 | + | if _, err := DiscoverCopilotAvailableModels(ctx, pat); err == nil { | |
| 71 | + | t.Error("cancellation ignored") | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestModelAvailabilityFiltersWithoutDeletingCatalog(t *testing.T) { | |
| 78 | + | t.Cleanup(func() { SetModelAvailability("github-copilot", nil) }) | |
| 79 | + | before := len(ModelsForProvider("anthropic")) | |
| 80 | + | ids := []string{"claude-sonnet-5"} | |
| 81 | + | SetModelAvailability("github-copilot", ids) | |
| 82 | + | ids[0] = "mutated" | |
| 83 | + | models := ModelsForProvider("github-copilot") | |
| 84 | + | if len(models) != 1 || models[0].ID != "claude-sonnet-5" { | |
| 85 | + | t.Fatalf("models = %v", models) | |
| 86 | + | } | |
| 87 | + | if len(ModelsForProvider("anthropic")) != before { | |
| 88 | + | t.Error("changed another provider") | |
| 89 | + | } | |
| 90 | + | SetModelAvailability("github-copilot", []string{}) | |
| 91 | + | // Hiding a model must not change wire metadata for a session already using it. | |
| 92 | + | model, err := FindModel("github-copilot", "gpt-5.4") | |
| 93 | + | if err != nil || model.API != APIResponses { | |
| 94 | + | t.Fatalf("availability refresh lost routing metadata: %+v, %v", model, err) | |
| 95 | + | } | |
| 96 | + | if len(ModelsForProvider("github-copilot")) != 0 { | |
| 97 | + | t.Error("empty availability did not hide models") | |
| 98 | + | } | |
| 99 | + | SetModelAvailability("github-copilot", nil) | |
| 100 | + | if len(ModelsForProvider("github-copilot")) != 38 { | |
| 101 | + | t.Error("clearing restriction did not restore catalog") | |
| 102 | + | } | |
| 103 | + | } | |
A packages/provider/discover_custom.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "net/url" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Conservative metadata for models that a list endpoint describes by ID | |
| 15 | + | // only. Explicit models.json entries override these values. | |
| 16 | + | const ( | |
| 17 | + | customDiscoveryContextWindow = 32768 | |
| 18 | + | customDiscoveryMaxOutput = 4096 | |
| 19 | + | customDiscoveryTimeout = 3 * time.Second | |
| 20 | + | customDiscoveryMaxBody = 8 << 20 | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // customModelsURL derives the list endpoint from a custom provider base URL. | |
| 24 | + | // A base that already ends in an API version segment such as "/v1" gets | |
| 25 | + | // "/models" appended; a bare host gets "/v1/models". | |
| 26 | + | func customModelsURL(baseURL string) (string, error) { | |
| 27 | + | u, err := url.Parse(strings.TrimSpace(baseURL)) | |
| 28 | + | if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { | |
| 29 | + | return "", fmt.Errorf("custom provider base URL must be an http or https URL") | |
| 30 | + | } | |
| 31 | + | u.Path = strings.TrimRight(u.Path, "/") | |
| 32 | + | u.RawPath = "" | |
| 33 | + | u.RawQuery = "" | |
| 34 | + | u.Fragment = "" | |
| 35 | + | u.User = nil | |
| 36 | + | base := u.String() | |
| 37 | + | if versionSegmentSuffix.MatchString(base) { | |
| 38 | + | return base + "/models", nil | |
| 39 | + | } | |
| 40 | + | return base + "/v1/models", nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // DiscoverCustomProvider lists models from a user-defined provider's | |
| 44 | + | // OpenAI-style /models endpoint using the process default HTTP transport, so | |
| 45 | + | // configured proxy settings apply. Pass an empty apiKey for keyless endpoints: | |
| 46 | + | // no Authorization header is sent. | |
| 47 | + | func DiscoverCustomProvider(ctx context.Context, providerID, baseURL, apiKey string) ([]Model, error) { | |
| 48 | + | return DiscoverCustomProviderWithClient(ctx, nil, providerID, baseURL, apiKey) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // DiscoverCustomProviderWithClient is DiscoverCustomProvider with a caller | |
| 52 | + | // supplied HTTP client, for example one from NewHTTPClient when the user scoped | |
| 53 | + | // insecure TLS to custom endpoints. A nil client uses the default transport. | |
| 54 | + | // The request is bounded by a short timeout and an 8 MiB response cap | |
| 55 | + | // regardless of the client's own settings. | |
| 56 | + | // | |
| 57 | + | // Only IDs are trusted from the response; context and output limits use | |
| 58 | + | // conservative defaults because self-hosted gateways rarely report them | |
| 59 | + | // consistently. Entries whose ID mentions embeddings are skipped. Discovered | |
| 60 | + | // models carry the provider's base URL so they route like models.json entries. | |
| 61 | + | func DiscoverCustomProviderWithClient(ctx context.Context, client *http.Client, providerID, baseURL, apiKey string) ([]Model, error) { | |
| 62 | + | providerID = strings.TrimSpace(providerID) | |
| 63 | + | if providerID == "" { | |
| 64 | + | return nil, fmt.Errorf("custom provider discovery requires a provider id") | |
| 65 | + | } | |
| 66 | + | endpoint, err := customModelsURL(baseURL) | |
| 67 | + | if err != nil { | |
| 68 | + | return nil, err | |
| 69 | + | } | |
| 70 | + | ctx, cancel := context.WithTimeout(ctx, customDiscoveryTimeout) | |
| 71 | + | defer cancel() | |
| 72 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) | |
| 73 | + | if err != nil { | |
| 74 | + | return nil, err | |
| 75 | + | } | |
| 76 | + | req.Header.Set("Accept", "application/json") | |
| 77 | + | if apiKey != "" { | |
| 78 | + | req.Header.Set("Authorization", "Bearer "+apiKey) | |
| 79 | + | } | |
| 80 | + | if client == nil { | |
| 81 | + | client = &http.Client{} | |
| 82 | + | } | |
| 83 | + | resp, err := client.Do(req) | |
| 84 | + | if err != nil { | |
| 85 | + | if ctx.Err() != nil { | |
| 86 | + | return nil, ctx.Err() | |
| 87 | + | } | |
| 88 | + | // The transport error can echo the URL; keep the message generic. | |
| 89 | + | return nil, fmt.Errorf("%s discovery request failed", providerID) | |
| 90 | + | } | |
| 91 | + | defer resp.Body.Close() | |
| 92 | + | if resp.StatusCode != http.StatusOK { | |
| 93 | + | return nil, fmt.Errorf("%s discovery HTTP %d", providerID, resp.StatusCode) | |
| 94 | + | } | |
| 95 | + | body, err := io.ReadAll(io.LimitReader(resp.Body, customDiscoveryMaxBody+1)) | |
| 96 | + | if err != nil { | |
| 97 | + | return nil, fmt.Errorf("%s discovery response could not be read", providerID) | |
| 98 | + | } | |
| 99 | + | if len(body) > customDiscoveryMaxBody { | |
| 100 | + | return nil, fmt.Errorf("%s discovery response too large", providerID) | |
| 101 | + | } | |
| 102 | + | var page struct { | |
| 103 | + | Data []struct { | |
| 104 | + | ID string `json:"id"` | |
| 105 | + | } `json:"data"` | |
| 106 | + | } | |
| 107 | + | if err := json.Unmarshal(body, &page); err != nil { | |
| 108 | + | return nil, fmt.Errorf("%s discovery returned invalid model data", providerID) | |
| 109 | + | } | |
| 110 | + | if page.Data == nil { | |
| 111 | + | return nil, fmt.Errorf("%s discovery response is missing data", providerID) | |
| 112 | + | } | |
| 113 | + | models := make([]Model, 0, len(page.Data)) | |
| 114 | + | seen := map[string]bool{} | |
| 115 | + | for _, d := range page.Data { | |
| 116 | + | id := strings.TrimSpace(d.ID) | |
| 117 | + | if id == "" || seen[id] || strings.Contains(strings.ToLower(id), "embed") { | |
| 118 | + | continue | |
| 119 | + | } | |
| 120 | + | seen[id] = true | |
| 121 | + | models = append(models, Model{ | |
| 122 | + | Provider: providerID, | |
| 123 | + | ID: id, | |
| 124 | + | DisplayName: id, | |
| 125 | + | ContextWindow: customDiscoveryContextWindow, | |
| 126 | + | MaxOutput: customDiscoveryMaxOutput, | |
| 127 | + | BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), | |
| 128 | + | Source: "live", | |
| 129 | + | }) | |
| 130 | + | } | |
| 131 | + | return models, nil | |
| 132 | + | } | |
A packages/provider/discover_custom_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestCustomModelsURL(t *testing.T) { | |
| 15 | + | cases := map[string]string{ | |
| 16 | + | "http://127.0.0.1:48000": "http://127.0.0.1:48000/v1/models", | |
| 17 | + | "http://127.0.0.1:48000/": "http://127.0.0.1:48000/v1/models", | |
| 18 | + | "http://127.0.0.1:48000/v1": "http://127.0.0.1:48000/v1/models", | |
| 19 | + | "http://127.0.0.1:48000/v1/": "http://127.0.0.1:48000/v1/models", | |
| 20 | + | "https://llm.example.com/paas/v4": "https://llm.example.com/paas/v4/models", | |
| 21 | + | } | |
| 22 | + | for in, want := range cases { | |
| 23 | + | got, err := customModelsURL(in) | |
| 24 | + | if err != nil || got != want { | |
| 25 | + | t.Errorf("customModelsURL(%q) = %q, %v; want %q", in, got, err, want) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | for _, bad := range []string{"", "localhost:1234", "ftp://x", "/v1"} { | |
| 29 | + | if _, err := customModelsURL(bad); err == nil { | |
| 30 | + | t.Errorf("customModelsURL(%q) accepted", bad) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestDiscoverCustomProvider(t *testing.T) { | |
| 36 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 37 | + | if r.URL.Path != "/v1/models" { | |
| 38 | + | t.Errorf("unexpected path %s", r.URL.Path) | |
| 39 | + | } | |
| 40 | + | if r.Header.Get("Authorization") != "Bearer dummy" { | |
| 41 | + | t.Errorf("missing bearer header: %q", r.Header.Get("Authorization")) | |
| 42 | + | } | |
| 43 | + | fmt.Fprint(w, `{"object":"list","data":[{"id":"qwen3-coder"},{"id":"nomic-embed-text"},{"id":"qwen3-coder"},{"id":""},{"id":"gpt-oss-20b"}]}`) | |
| 44 | + | })) | |
| 45 | + | defer server.Close() | |
| 46 | + | ||
| 47 | + | models, err := DiscoverCustomProvider(context.Background(), "m4", server.URL+"/v1", "dummy") | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | if len(models) != 2 { | |
| 52 | + | t.Fatalf("models = %+v", models) | |
| 53 | + | } | |
| 54 | + | first := models[0] | |
| 55 | + | if first.Provider != "m4" || first.ID != "qwen3-coder" || first.Source != "live" || first.BaseURL != server.URL+"/v1" { | |
| 56 | + | t.Fatalf("model = %+v", first) | |
| 57 | + | } | |
| 58 | + | if first.ContextWindow != customDiscoveryContextWindow || first.MaxOutput != customDiscoveryMaxOutput { | |
| 59 | + | t.Fatalf("defaults = %d/%d", first.ContextWindow, first.MaxOutput) | |
| 60 | + | } | |
| 61 | + | if models[1].ID != "gpt-oss-20b" { | |
| 62 | + | t.Fatalf("second model = %+v", models[1]) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestDiscoverCustomProviderOmitsHeaderWithoutKey(t *testing.T) { | |
| 67 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 68 | + | if _, ok := r.Header["Authorization"]; ok { | |
| 69 | + | t.Error("unexpected Authorization header") | |
| 70 | + | } | |
| 71 | + | fmt.Fprint(w, `{"data":[{"id":"local"}]}`) | |
| 72 | + | })) | |
| 73 | + | defer server.Close() | |
| 74 | + | models, err := DiscoverCustomProvider(context.Background(), "m5", server.URL, "") | |
| 75 | + | if err != nil || len(models) != 1 { | |
| 76 | + | t.Fatalf("models=%+v err=%v", models, err) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestDiscoverCustomProviderErrors(t *testing.T) { | |
| 81 | + | status := http.StatusUnauthorized | |
| 82 | + | body := `{}` | |
| 83 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 84 | + | w.WriteHeader(status) | |
| 85 | + | fmt.Fprint(w, body) | |
| 86 | + | })) | |
| 87 | + | defer server.Close() | |
| 88 | + | ||
| 89 | + | if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil || err.Error() != "m4 discovery HTTP 401" { | |
| 90 | + | t.Fatalf("status error = %v", err) | |
| 91 | + | } | |
| 92 | + | status = http.StatusOK | |
| 93 | + | body = `{"object":"list"}` | |
| 94 | + | if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil { | |
| 95 | + | t.Fatal("missing data accepted") | |
| 96 | + | } | |
| 97 | + | body = `not json` | |
| 98 | + | if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil { | |
| 99 | + | t.Fatal("invalid JSON accepted") | |
| 100 | + | } | |
| 101 | + | if _, err := DiscoverCustomProvider(context.Background(), "", server.URL, "k"); err == nil { | |
| 102 | + | t.Fatal("empty provider id accepted") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 106 | + | cancel() | |
| 107 | + | if _, err := DiscoverCustomProvider(ctx, "m4", server.URL, "k"); !errors.Is(err, context.Canceled) { | |
| 108 | + | t.Fatalf("cancellation error = %v", err) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestDiscoverCustomProviderBoundsResponseAndUsesClient(t *testing.T) { | |
| 113 | + | big := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 114 | + | fmt.Fprint(w, `{"data":[{"id":"`) | |
| 115 | + | chunk := make([]byte, 1<<20) | |
| 116 | + | for i := range chunk { | |
| 117 | + | chunk[i] = 'a' | |
| 118 | + | } | |
| 119 | + | for i := 0; i < 9; i++ { | |
| 120 | + | w.Write(chunk) | |
| 121 | + | } | |
| 122 | + | fmt.Fprint(w, `"}]}`) | |
| 123 | + | })) | |
| 124 | + | defer big.Close() | |
| 125 | + | if _, err := DiscoverCustomProvider(context.Background(), "m4", big.URL, ""); err == nil { | |
| 126 | + | t.Fatal("oversized response accepted") | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | used := false | |
| 130 | + | client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { | |
| 131 | + | used = true | |
| 132 | + | return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x"}]}`)), Header: http.Header{}}, nil | |
| 133 | + | })} | |
| 134 | + | models, err := DiscoverCustomProviderWithClient(context.Background(), client, "m4", "http://example.invalid/v1", "") | |
| 135 | + | if err != nil || !used || len(models) != 1 { | |
| 136 | + | t.Fatalf("custom client not used: used=%v models=%v err=%v", used, models, err) | |
| 137 | + | } | |
| 138 | + | } | |
M packages/provider/github_copilot.go +63 -14
| @@ -14,12 +14,11 @@ package provider | |||
| 14 | 14 | // User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`. | |
| 15 | 15 | // 3. The short-lived token's value embeds a `proxy-ep=<host>` field that | |
| 16 | 16 | // tells us the real API host (individual users: api.individual.githubcopilot.com). | |
| 17 | - | // 4. Inference requests go to `<host>/chat/completions` with the | |
| 17 | + | // 4. Inference requests use Messages, Responses, or Chat Completions with the | |
| 18 | 18 | // short-lived token in `Authorization: Bearer` plus extras: | |
| 19 | 19 | // - X-Initiator: user|agent | |
| 20 | 20 | // - Openai-Intent: conversation-edits | |
| 21 | - | // - Copilot-Vision-Request: true (when images present; not wired | |
| 22 | - | // here because the zut openai client currently sends images inline) | |
| 21 | + | // - Copilot-Vision-Request: true (when images are present) | |
| 23 | 22 | // | |
| 24 | 23 | // Token caching: short-lived tokens last ~30min. We cache one per PAT in | |
| 25 | 24 | // memory for the process lifetime and refresh on demand. No disk cache. | |
| @@ -162,12 +161,21 @@ func (t *copilotRefreshTransport) RoundTrip(req *http.Request) (*http.Response, | |||
| 162 | 161 | } | |
| 163 | 162 | clone := req.Clone(req.Context()) | |
| 164 | 163 | clone.Header.Set("Authorization", "Bearer "+tok.value) | |
| 164 | + | // Messages uses Copilot Bearer auth, never an Anthropic API key. | |
| 165 | + | clone.Header.Del("x-api-key") | |
| 165 | 166 | // Identity headers also required on inference requests. | |
| 166 | 167 | for k, v := range copilotIdentityHeaders { | |
| 167 | 168 | clone.Header.Set(k, v) | |
| 168 | 169 | } | |
| 169 | - | clone.Header.Set("X-Initiator", "agent") | |
| 170 | - | clone.Header.Set("Openai-Intent", "conversation-edits") | |
| 170 | + | if metadata, ok := req.Context().Value(copilotRequestKey{}).(copilotRequestMetadata); ok { | |
| 171 | + | clone.Header.Set("X-Initiator", metadata.initiator) | |
| 172 | + | if metadata.vision { | |
| 173 | + | clone.Header.Set("Copilot-Vision-Request", "true") | |
| 174 | + | } else { | |
| 175 | + | clone.Header.Del("Copilot-Vision-Request") | |
| 176 | + | } | |
| 177 | + | clone.Header.Set("Openai-Intent", "conversation-edits") | |
| 178 | + | } | |
| 171 | 179 | // If the request URL host doesn't match the token's proxy-ep, rewrite | |
| 172 | 180 | // it. The openaiClient pinned a static host at construction time, but | |
| 173 | 181 | // the canonical host comes from the token. | |
| @@ -181,20 +189,61 @@ func (t *copilotRefreshTransport) RoundTrip(req *http.Request) (*http.Response, | |||
| 181 | 189 | return t.inner.RoundTrip(clone) | |
| 182 | 190 | } | |
| 183 | 191 | ||
| 184 | - | // NewGithubCopilotClient returns a Copilot-pinned OpenAI-compat client. | |
| 185 | - | // The pat must be a GitHub Personal Access Token with Copilot access. | |
| 192 | + | // copilotResponsesStripTransport removes the codex client's ChatGPT | |
| 193 | + | // OAuth identity headers before handing off to copilotRefreshTransport, | |
| 194 | + | // which applies the Copilot token and identity headers. The Copilot | |
| 195 | + | // proxy rejects requests that carry chatgpt-account-id / openai-beta. | |
| 196 | + | type copilotResponsesStripTransport struct { | |
| 197 | + | inner http.RoundTripper | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func (t *copilotResponsesStripTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |
| 201 | + | clone := req.Clone(req.Context()) | |
| 202 | + | clone.Header.Del("chatgpt-account-id") | |
| 203 | + | clone.Header.Del("openai-beta") | |
| 204 | + | clone.Header.Del("originator") | |
| 205 | + | return t.inner.RoundTrip(clone) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | const copilotDefaultBaseURL = "https://api.individual.githubcopilot.com" | |
| 209 | + | ||
| 210 | + | // NewGithubCopilotClient uses Messages for Claude, Responses for GPT/Grok/MAI, | |
| 211 | + | // and Chat Completions for Gemini/Kimi. The PAT is exchanged for Copilot tokens. | |
| 186 | 212 | func NewGithubCopilotClient(pat string) Client { | |
| 187 | - | httpClient := &http.Client{ | |
| 188 | - | Transport: &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}, | |
| 189 | - | Timeout: 0, | |
| 190 | - | } | |
| 213 | + | refresh := &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat} | |
| 214 | + | completionsHTTP := &http.Client{Transport: refresh, Timeout: 0} | |
| 191 | 215 | // Initial baseURL is a sane default; copilotRefreshTransport rewrites | |
| 192 | 216 | // the host on every request based on the freshly-issued token. | |
| 193 | - | return &openaiClient{ | |
| 217 | + | completions := &openaiClient{ | |
| 194 | 218 | apiKey: pat, // unused at the wire level (transport overrides Auth) but kept for parity | |
| 195 | - | baseURL: "https://api.individual.githubcopilot.com", | |
| 219 | + | baseURL: copilotDefaultBaseURL, | |
| 196 | 220 | chatCompletionsPath: "/chat/completions", | |
| 197 | 221 | name: "github-copilot", | |
| 198 | - | http: httpClient, | |
| 222 | + | http: completionsHTTP, | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | responsesHTTP := &http.Client{ | |
| 226 | + | Transport: &copilotResponsesStripTransport{inner: refresh}, | |
| 227 | + | Timeout: 0, | |
| 199 | 228 | } | |
| 229 | + | responses := &codexClient{ | |
| 230 | + | token: pat, // unused at the wire level (transport overrides Auth) but kept for parity | |
| 231 | + | baseURL: copilotDefaultBaseURL + "/responses", | |
| 232 | + | errorLabel: "github copilot", | |
| 233 | + | providerName: "github-copilot", | |
| 234 | + | disableCLIRouting: true, | |
| 235 | + | http: responsesHTTP, | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | messages := &anthropicClient{ | |
| 239 | + | name: "github-copilot", | |
| 240 | + | baseURL: copilotDefaultBaseURL, | |
| 241 | + | // Do not set oauthTok: that would inject Claude Code identity and rename tools. | |
| 242 | + | http: &http.Client{Transport: refresh}, | |
| 243 | + | } | |
| 244 | + | return &copilotClient{router: NewModelRouter("github-copilot", completions, map[string]Client{ | |
| 245 | + | APICompletions: completions, | |
| 246 | + | APIResponses: responses, | |
| 247 | + | APIAnthropicMessages: messages, | |
| 248 | + | }).(*modelRouter)} | |
| 200 | 249 | } | |
A packages/provider/github_copilot_request.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "regexp" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type copilotRequestKey struct{} | |
| 11 | + | type copilotRequestMetadata struct { | |
| 12 | + | initiator string | |
| 13 | + | vision bool | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | type copilotClient struct { | |
| 17 | + | router *modelRouter | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (c *copilotClient) Name() string { return "github-copilot" } | |
| 21 | + | ||
| 22 | + | // SetModelMetadata lets runtime-owned model switches reach the wire clients. | |
| 23 | + | func (c *copilotClient) SetModelMetadata(model Model) { c.router.SetModelMetadata(model) } | |
| 24 | + | ||
| 25 | + | func (c *copilotClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 26 | + | metadata := copilotRequestMetadata{initiator: "user"} | |
| 27 | + | if len(req.Messages) > 0 && req.Messages[len(req.Messages)-1].Role != RoleUser { | |
| 28 | + | metadata.initiator = "agent" | |
| 29 | + | } | |
| 30 | + | for _, message := range req.Messages { | |
| 31 | + | if message.Role == RoleUser || message.Role == RoleTool { | |
| 32 | + | metadata.vision = metadata.vision || copilotHasImages(message.Content) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | // One catalog lookup decides both reasoning and route. | |
| 36 | + | model, findErr := FindModel(c.Name(), req.Model) | |
| 37 | + | tagged := findErr == nil && model.API != "" | |
| 38 | + | api := copilotModelAPI(req.Model) | |
| 39 | + | if tagged { | |
| 40 | + | api = model.API | |
| 41 | + | } | |
| 42 | + | // Chat Completions endpoints perform their own thinking; they reject | |
| 43 | + | // reasoning_effort. | |
| 44 | + | if api == APICompletions { | |
| 45 | + | req.Reasoning = "" | |
| 46 | + | } | |
| 47 | + | ctx = context.WithValue(ctx, copilotRequestKey{}, metadata) | |
| 48 | + | if tagged { | |
| 49 | + | return c.router.Stream(ctx, req) | |
| 50 | + | } | |
| 51 | + | // Use family routing for custom models that have not supplied an API tag. | |
| 52 | + | routed := c.router.byAPI[api] | |
| 53 | + | if routed == nil { | |
| 54 | + | return nil, fmt.Errorf("provider %q model %q has no client for wire API %q", c.Name(), req.Model, api) | |
| 55 | + | } | |
| 56 | + | return routed.Stream(ctx, req) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func copilotHasImages(blocks []Content) bool { | |
| 60 | + | for _, block := range blocks { | |
| 61 | + | switch b := block.(type) { | |
| 62 | + | case ImageBlock: | |
| 63 | + | return true | |
| 64 | + | case ToolResultBlock: | |
| 65 | + | if copilotHasImages(b.Content) { | |
| 66 | + | return true | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func copilotModelAPI(id string) string { | |
| 74 | + | switch { | |
| 75 | + | case strings.HasPrefix(id, "claude-"): | |
| 76 | + | return APIAnthropicMessages | |
| 77 | + | case strings.HasPrefix(id, "gpt-4"), strings.HasPrefix(id, "grok-code-"): | |
| 78 | + | // Legacy chat-only families keep Chat Completions. | |
| 79 | + | return APICompletions | |
| 80 | + | case strings.HasPrefix(id, "gpt-"), strings.HasPrefix(id, "grok-"), strings.HasPrefix(id, "mai-"), strings.HasPrefix(id, "oswe"): | |
| 81 | + | return APIResponses | |
| 82 | + | default: | |
| 83 | + | return APICompletions | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func configureCopilotModel(m *Model) { | |
| 88 | + | switch m.API { | |
| 89 | + | case "": | |
| 90 | + | m.API = copilotModelAPI(m.ID) | |
| 91 | + | case "anthropic": | |
| 92 | + | // Legacy models.json api values map onto Copilot's wire protocols. | |
| 93 | + | m.API = APIAnthropicMessages | |
| 94 | + | case "openai": | |
| 95 | + | m.API = APICompletions | |
| 96 | + | } | |
| 97 | + | // Claude Haiku, 4.5 and 4.6 use explicit budgets: the canonical Anthropic | |
| 98 | + | // metadata (usesAdaptiveThinking) does not treat 4.6 as adaptive. Newer or | |
| 99 | + | // unknown Claude ids keep the adaptive default; explicit metadata wins. | |
| 100 | + | if m.API == APIAnthropicMessages && !usesExplicitThinkingBudget(m.ID) { | |
| 101 | + | m.AdaptiveThinking = true | |
| 102 | + | } | |
| 103 | + | // An explicit mapping (for example from models.json) always wins. | |
| 104 | + | if m.ReasoningLevelMap != nil { | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | if m.API == APICompletions { | |
| 108 | + | m.ReasoningLevelMap = map[string]string{"minimum": "", "low": "", "medium": "", "high": "", "xhigh": "", "max": ""} | |
| 109 | + | } | |
| 110 | + | // Responses defaults expose xhigh, but these models only support low/medium/high. | |
| 111 | + | if m.ID == "gpt-5-mini" || m.ID == "grok-4.5" || strings.HasPrefix(m.ID, "mai-") { | |
| 112 | + | m.ReasoningLevelMap = map[string]string{"xhigh": "", "max": ""} | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func usesExplicitThinkingBudget(id string) bool { | |
| 117 | + | if strings.HasPrefix(id, "claude-haiku-") { | |
| 118 | + | return true | |
| 119 | + | } | |
| 120 | + | // Claude 4 through 4.6 predate adaptive thinking (4.7 is the first). | |
| 121 | + | return copilotLegacyClaudeVersion.MatchString(id) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | var copilotLegacyClaudeVersion = regexp.MustCompile(`-4(\.[0-6])?$`) | |
M packages/provider/github_copilot_test.go +29 -0
| @@ -31,6 +31,11 @@ func TestCopilotHostRewrite(t *testing.T) { | |||
| 31 | 31 | baseURL: "https://api.enterprise.copilot.proxy", | |
| 32 | 32 | } | |
| 33 | 33 | copilotCache.mu.Unlock() | |
| 34 | + | t.Cleanup(func() { | |
| 35 | + | copilotCache.mu.Lock() | |
| 36 | + | delete(copilotCache.tokens, pat) | |
| 37 | + | copilotCache.mu.Unlock() | |
| 38 | + | }) | |
| 34 | 39 | ||
| 35 | 40 | mockRT := &mockRoundTripper{} | |
| 36 | 41 | transport := &copilotRefreshTransport{ | |
| @@ -68,3 +73,27 @@ func TestCopilotHostRewrite(t *testing.T) { | |||
| 68 | 73 | t.Errorf("expected Host header (Request.Host) to be %q, got %q", wantHost, mockRT.lastReq.Host) | |
| 69 | 74 | } | |
| 70 | 75 | } | |
| 76 | + | ||
| 77 | + | func TestCopilotRoutesModernGPTToResponses(t *testing.T) { | |
| 78 | + | client, ok := NewGithubCopilotClient("pat").(*copilotClient) | |
| 79 | + | if !ok { | |
| 80 | + | t.Fatal("NewGithubCopilotClient did not return a copilotClient") | |
| 81 | + | } | |
| 82 | + | codex, ok := client.router.byAPI[APIResponses].(*codexClient) | |
| 83 | + | if !ok { | |
| 84 | + | t.Fatal("github-copilot Responses client is not a codexClient") | |
| 85 | + | } | |
| 86 | + | const want = "https://api.individual.githubcopilot.com/responses" | |
| 87 | + | if codex.baseURL != want { | |
| 88 | + | t.Errorf("github-copilot Responses baseURL = %q, want %q", codex.baseURL, want) | |
| 89 | + | } | |
| 90 | + | for _, id := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra"} { | |
| 91 | + | m, err := FindModel("github-copilot", id) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatalf("FindModel(%q): %v", id, err) | |
| 94 | + | } | |
| 95 | + | if m.API != APIResponses { | |
| 96 | + | t.Errorf("model %q API = %q, want %q", id, m.API, APIResponses) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
A packages/provider/github_copilot_wire_test.go +221 -0
| @@ -0,0 +1,221 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func seedCopilotTestToken(t *testing.T, baseURL string) string { | |
| 15 | + | t.Helper() | |
| 16 | + | pat := "synthetic-" + t.Name() | |
| 17 | + | copilotCache.mu.Lock() | |
| 18 | + | copilotCache.tokens[pat] = copilotToken{value: "synthetic-session-token", baseURL: baseURL, expiresAt: time.Now().Add(time.Hour)} | |
| 19 | + | copilotCache.mu.Unlock() | |
| 20 | + | t.Cleanup(func() { | |
| 21 | + | copilotCache.mu.Lock() | |
| 22 | + | delete(copilotCache.tokens, pat) | |
| 23 | + | copilotCache.mu.Unlock() | |
| 24 | + | }) | |
| 25 | + | return pat | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestCopilotWireProtocols(t *testing.T) { | |
| 29 | + | for _, tc := range []struct{ model, path string }{ | |
| 30 | + | {"claude-sonnet-5", "/v1/messages"}, | |
| 31 | + | {"claude-haiku-4.5", "/v1/messages"}, | |
| 32 | + | {"gpt-5.4", "/responses"}, | |
| 33 | + | {"grok-4.6", "/responses"}, | |
| 34 | + | {"mai-code-1.1-flash", "/responses"}, | |
| 35 | + | {"gemini-3.8-flash", "/chat/completions"}, | |
| 36 | + | {"kimi-k3", "/chat/completions"}, | |
| 37 | + | } { | |
| 38 | + | t.Run(tc.model, func(t *testing.T) { | |
| 39 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 40 | + | if r.URL.Path != tc.path { | |
| 41 | + | t.Errorf("path = %q, want %q", r.URL.Path, tc.path) | |
| 42 | + | } | |
| 43 | + | if r.Header.Get("Authorization") != "Bearer synthetic-session-token" { | |
| 44 | + | t.Error("missing session Bearer auth") | |
| 45 | + | } | |
| 46 | + | if r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-Initiator") != "user" || r.Header.Get("Openai-Intent") != "conversation-edits" { | |
| 47 | + | t.Error("missing Copilot identity headers") | |
| 48 | + | } | |
| 49 | + | for _, header := range []string{"x-api-key", "anthropic-beta", "x-app", "chatgpt-account-id", "openai-beta", "originator"} { | |
| 50 | + | if r.Header.Get(header) != "" { | |
| 51 | + | t.Errorf("unexpected identity header %s", header) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | var body map[string]json.RawMessage | |
| 55 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 56 | + | t.Error(err) | |
| 57 | + | w.WriteHeader(400) | |
| 58 | + | return | |
| 59 | + | } | |
| 60 | + | if string(body["model"]) != fmt.Sprintf("%q", tc.model) { | |
| 61 | + | t.Error("model ID changed on wire") | |
| 62 | + | } | |
| 63 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 64 | + | switch tc.path { | |
| 65 | + | case "/v1/messages": | |
| 66 | + | if len(body["messages"]) == 0 || len(body["max_tokens"]) == 0 { | |
| 67 | + | t.Error("missing Messages fields") | |
| 68 | + | } | |
| 69 | + | if strings.Contains(string(body["system"]), "Claude Code") { | |
| 70 | + | t.Error("injected Claude Code system identity") | |
| 71 | + | } | |
| 72 | + | if tc.model == "claude-sonnet-5" || tc.model == "claude-opus-5.5" { | |
| 73 | + | if string(body["thinking"]) != `{"type":"adaptive"}` { | |
| 74 | + | t.Errorf("thinking = %s", body["thinking"]) | |
| 75 | + | } | |
| 76 | + | if len(body["output_config"]) == 0 || len(body["temperature"]) != 0 { | |
| 77 | + | t.Error("invalid adaptive thinking payload") | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | fmt.Fprint(w, "event: message_start\ndata: {\"message\":{\"id\":\"test\",\"usage\":{\"input_tokens\":1}}}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {}\n\n") | |
| 81 | + | case "/responses": | |
| 82 | + | if len(body["input"]) == 0 || len(body["reasoning"]) == 0 { | |
| 83 | + | t.Error("missing Responses fields") | |
| 84 | + | } | |
| 85 | + | fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") | |
| 86 | + | default: | |
| 87 | + | if len(body["reasoning_effort"]) != 0 { | |
| 88 | + | t.Error("unsupported reasoning_effort sent") | |
| 89 | + | } | |
| 90 | + | if len(body["messages"]) == 0 { | |
| 91 | + | t.Error("missing chat messages") | |
| 92 | + | } | |
| 93 | + | fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") | |
| 94 | + | } | |
| 95 | + | })) | |
| 96 | + | defer srv.Close() | |
| 97 | + | client := NewGithubCopilotClient(seedCopilotTestToken(t, srv.URL)) | |
| 98 | + | stream, err := client.Stream(context.Background(), Request{Model: tc.model, Reasoning: "high", System: "Test system", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "Hello"}}}}}) | |
| 99 | + | if err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | for event := range stream { | |
| 103 | + | if e, ok := event.(EventDone); ok && e.Err != nil { | |
| 104 | + | t.Fatalf("stream error: %v", e.Err) | |
| 105 | + | } | |
| 106 | + | if e, ok := event.(EventStart); ok && e.Provider != "github-copilot" { | |
| 107 | + | t.Errorf("event provider = %s", e.Provider) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | type copilotMetadataCapture struct { | |
| 115 | + | metadata copilotRequestMetadata | |
| 116 | + | reasoning string | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (c *copilotMetadataCapture) Name() string { return "github-copilot" } | |
| 120 | + | func (c *copilotMetadataCapture) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 121 | + | c.metadata = ctx.Value(copilotRequestKey{}).(copilotRequestMetadata) | |
| 122 | + | c.reasoning = req.Reasoning | |
| 123 | + | ch := make(chan Event) | |
| 124 | + | close(ch) | |
| 125 | + | return ch, nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestCopilotDynamicRequestMetadata(t *testing.T) { | |
| 129 | + | client := NewGithubCopilotClient("unused").(*copilotClient) | |
| 130 | + | capture := &copilotMetadataCapture{} | |
| 131 | + | client.router.byAPI[APICompletions] = capture | |
| 132 | + | for _, tc := range []struct { | |
| 133 | + | messages []Message | |
| 134 | + | initiator string | |
| 135 | + | vision bool | |
| 136 | + | }{ | |
| 137 | + | {nil, "user", false}, | |
| 138 | + | {[]Message{{Role: RoleUser, Content: []Content{ImageBlock{}}}}, "user", true}, | |
| 139 | + | {[]Message{{Role: RoleTool, Content: []Content{ToolResultBlock{Content: []Content{ImageBlock{}}}}}}, "agent", true}, | |
| 140 | + | {[]Message{{Role: RoleAssistant}}, "agent", false}, | |
| 141 | + | {[]Message{{Role: RoleUser}}, "user", false}, | |
| 142 | + | } { | |
| 143 | + | _, err := client.Stream(context.Background(), Request{Model: "kimi-k3", Reasoning: "high", Messages: tc.messages}) | |
| 144 | + | if err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | if capture.metadata.initiator != tc.initiator || capture.metadata.vision != tc.vision || capture.reasoning != "" { | |
| 148 | + | t.Errorf("unexpected metadata: %+v, reasoning %q", capture.metadata, capture.reasoning) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | // The transport must propagate vision metadata and remove it on the next text-only request. | |
| 152 | + | mock := &mockRoundTripper{} | |
| 153 | + | transport := &copilotRefreshTransport{inner: mock, pat: seedCopilotTestToken(t, copilotDefaultBaseURL)} | |
| 154 | + | for _, vision := range []bool{true, false} { | |
| 155 | + | ctx := context.WithValue(context.Background(), copilotRequestKey{}, copilotRequestMetadata{initiator: "agent", vision: vision}) | |
| 156 | + | req, _ := http.NewRequestWithContext(ctx, "POST", copilotDefaultBaseURL+"/responses", nil) | |
| 157 | + | req.Header.Set("Copilot-Vision-Request", "stale") | |
| 158 | + | resp, err := transport.RoundTrip(req) | |
| 159 | + | if err != nil { | |
| 160 | + | t.Fatal(err) | |
| 161 | + | } | |
| 162 | + | resp.Body.Close() | |
| 163 | + | want := "" | |
| 164 | + | if vision { | |
| 165 | + | want = "true" | |
| 166 | + | } | |
| 167 | + | if mock.lastReq.Header.Get("Copilot-Vision-Request") != want || mock.lastReq.Header.Get("X-Initiator") != "agent" { | |
| 168 | + | t.Error("incorrect dynamic transport headers") | |
| 169 | + | } | |
| 170 | + | if req.Header.Get("Copilot-Vision-Request") != "stale" { | |
| 171 | + | t.Error("transport mutated caller headers") | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestCopilotUserOnlyModelsGetProtocolDefaults(t *testing.T) { | |
| 177 | + | snapshot := SnapshotCatalog() | |
| 178 | + | t.Cleanup(func() { RestoreCatalog(snapshot) }) | |
| 179 | + | SetUserModels([]Model{ | |
| 180 | + | {Provider: "github-copilot", ID: "claude-custom-9", Reasoning: true, ContextWindow: 200000, MaxOutput: 8000, Source: "user"}, | |
| 181 | + | {Provider: "github-copilot", ID: "kimi-custom", Reasoning: true, ContextWindow: 200000, MaxOutput: 8000, Source: "user"}, | |
| 182 | + | {Provider: "github-copilot", ID: "gemini-explicit", API: APICompletions, Reasoning: true, ReasoningLevelMap: map[string]string{"high": "high"}, ContextWindow: 200000, MaxOutput: 8000, Source: "user"}, | |
| 183 | + | {Provider: "github-copilot", ID: "legacy-anthropic", API: "anthropic", Reasoning: true, ContextWindow: 200000, MaxOutput: 8000, Source: "user"}, | |
| 184 | + | }) | |
| 185 | + | ||
| 186 | + | claude, err := FindModel("github-copilot", "claude-custom-9") | |
| 187 | + | if err != nil || claude.API != APIAnthropicMessages || !claude.AdaptiveThinking { | |
| 188 | + | t.Fatalf("claude user model = %+v, %v", claude, err) | |
| 189 | + | } | |
| 190 | + | kimi, _ := FindModel("github-copilot", "kimi-custom") | |
| 191 | + | if kimi.API != APICompletions || ClampReasoningForModel(kimi, "high") != "" { | |
| 192 | + | t.Fatalf("chat user model keeps reasoning: %+v", kimi) | |
| 193 | + | } | |
| 194 | + | explicit, _ := FindModel("github-copilot", "gemini-explicit") | |
| 195 | + | if got := ClampReasoningForModel(explicit, "high"); got != "high" { | |
| 196 | + | t.Fatalf("explicit user mapping overwritten: clamp = %q", got) | |
| 197 | + | } | |
| 198 | + | legacy, _ := FindModel("github-copilot", "legacy-anthropic") | |
| 199 | + | if legacy.API != APIAnthropicMessages { | |
| 200 | + | t.Fatalf("legacy api = %q", legacy.API) | |
| 201 | + | } | |
| 202 | + | // Catalog rows overridden by models.json keep their protocol. | |
| 203 | + | SetUserModels([]Model{{Provider: "github-copilot", ID: "kimi-k3", API: "openai", Reasoning: true, Source: "user"}}) | |
| 204 | + | if m, _ := FindModel("github-copilot", "kimi-k3"); m.API != APICompletions || ClampReasoningForModel(m, "high") != "" { | |
| 205 | + | t.Fatalf("overridden catalog row = %+v", m) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // Route: family routing for untagged ids and one lookup for reasoning. | |
| 209 | + | SetUserModels([]Model{ | |
| 210 | + | {Provider: "github-copilot", ID: "kimi-custom", Reasoning: true, ContextWindow: 200000, MaxOutput: 8000, Source: "user"}, | |
| 211 | + | }) | |
| 212 | + | client := NewGithubCopilotClient("unused").(*copilotClient) | |
| 213 | + | capture := &copilotMetadataCapture{} | |
| 214 | + | client.router.byAPI[APICompletions] = capture | |
| 215 | + | if _, err := client.Stream(context.Background(), Request{Model: "kimi-custom", Reasoning: "high"}); err != nil { | |
| 216 | + | t.Fatal(err) | |
| 217 | + | } | |
| 218 | + | if capture.reasoning != "" { | |
| 219 | + | t.Fatalf("reasoning_effort leaked to chat route: %q", capture.reasoning) | |
| 220 | + | } | |
| 221 | + | } | |
M packages/provider/models.go +96 -6
| @@ -491,8 +491,9 @@ var ( | |||
| 491 | 491 | active []Model // live overlay merged in via SetLiveModels; nil = none yet | |
| 492 | 492 | activeSet bool // true once SetLiveModels has run (even with empty live) | |
| 493 | 493 | authoritativeProviderSet map[string]struct{} | |
| 494 | - | managedModels []Model // ephemeral models exposed by local model managers | |
| 495 | - | userModels []Model // highest-precedence models loaded from models.json | |
| 494 | + | managedModels []Model // ephemeral models exposed by local model managers | |
| 495 | + | userModels []Model // highest-precedence models loaded from models.json | |
| 496 | + | modelAvailability = map[string]map[string]bool{} // account-specific, memory only | |
| 496 | 497 | ) | |
| 497 | 498 | ||
| 498 | 499 | // CatalogSnapshot captures the mutable catalog overlays so a caller can | |
| @@ -505,6 +506,7 @@ type CatalogSnapshot struct { | |||
| 505 | 506 | authoritativeProviderSet map[string]struct{} | |
| 506 | 507 | managedModels []Model | |
| 507 | 508 | userModels []Model | |
| 509 | + | modelAvailability map[string]map[string]bool | |
| 508 | 510 | } | |
| 509 | 511 | ||
| 510 | 512 | // SnapshotCatalog returns a deep copy of the mutable provider catalog state. | |
| @@ -515,6 +517,7 @@ func SnapshotCatalog() CatalogSnapshot { | |||
| 515 | 517 | snapshot := CatalogSnapshot{ | |
| 516 | 518 | activeSet: activeSet, | |
| 517 | 519 | authoritativeProviderSet: maps.Clone(authoritativeProviderSet), | |
| 520 | + | modelAvailability: cloneModelAvailability(modelAvailability), | |
| 518 | 521 | } | |
| 519 | 522 | if active != nil { | |
| 520 | 523 | snapshot.active = cloneModels(active) | |
| @@ -540,6 +543,10 @@ func RestoreCatalog(snapshot CatalogSnapshot) { | |||
| 540 | 543 | } | |
| 541 | 544 | activeSet = snapshot.activeSet | |
| 542 | 545 | authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet) | |
| 546 | + | modelAvailability = cloneModelAvailability(snapshot.modelAvailability) | |
| 547 | + | if modelAvailability == nil { | |
| 548 | + | modelAvailability = map[string]map[string]bool{} | |
| 549 | + | } | |
| 543 | 550 | if snapshot.managedModels != nil { | |
| 544 | 551 | managedModels = cloneModels(snapshot.managedModels) | |
| 545 | 552 | } else { | |
| @@ -629,9 +636,18 @@ func mergeUserModels(active, users []Model) []Model { | |||
| 629 | 636 | } | |
| 630 | 637 | existing.Source = "user" | |
| 631 | 638 | existing.Speculative = false | |
| 639 | + | if existing.Provider == ProviderGitHubCopilot { | |
| 640 | + | configureCopilotModel(&existing) // normalizes a legacy api value | |
| 641 | + | } | |
| 632 | 642 | active[idx] = existing | |
| 633 | 643 | continue | |
| 634 | 644 | } | |
| 645 | + | if user.Provider == ProviderGitHubCopilot { | |
| 646 | + | // A user-only Copilot model has no catalog row, so it would miss the | |
| 647 | + | // protocol-specific defaults built-in rows receive. Explicit API and | |
| 648 | + | // reasoning mappings from models.json are preserved. | |
| 649 | + | configureCopilotModel(&user) | |
| 650 | + | } | |
| 635 | 651 | index[key] = len(active) | |
| 636 | 652 | active = append(active, user) | |
| 637 | 653 | } | |
| @@ -692,7 +708,11 @@ func ClearLiveModelsForProvider(name string) { | |||
| 692 | 708 | // Snapshotting Catalog at var-init time would freeze the picker to the | |
| 693 | 709 | // curated seed list and drop every extra provider (openrouter, groq, | |
| 694 | 710 | // xai, ...). Deferring the read to call time avoids that ordering trap. | |
| 695 | - | func Active() []Model { | |
| 711 | + | func Active() []Model { return activeModels(true) } | |
| 712 | + | ||
| 713 | + | // activeModels builds the merged catalog. When availableOnly is set, providers | |
| 714 | + | // with an account availability snapshot expose only the models it lists. | |
| 715 | + | func activeModels(availableOnly bool) []Model { | |
| 696 | 716 | activeMu.RLock() | |
| 697 | 717 | defer activeMu.RUnlock() | |
| 698 | 718 | src := active | |
| @@ -719,7 +739,56 @@ func Active() []Model { | |||
| 719 | 739 | // User models are a durable, highest-precedence overlay. Apply them | |
| 720 | 740 | // after the live and managed overlays so a catalog refresh cannot | |
| 721 | 741 | // discard overrides or user-only entries. | |
| 722 | - | return mergeUserModels(out, userModels) | |
| 742 | + | out = mergeUserModels(out, userModels) | |
| 743 | + | if availableOnly { | |
| 744 | + | out = filterAvailableModels(out) | |
| 745 | + | } | |
| 746 | + | return out | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | func cloneModelAvailability(in map[string]map[string]bool) map[string]map[string]bool { | |
| 750 | + | if in == nil { | |
| 751 | + | return nil | |
| 752 | + | } | |
| 753 | + | out := make(map[string]map[string]bool, len(in)) | |
| 754 | + | for name, ids := range in { | |
| 755 | + | out[name] = maps.Clone(ids) | |
| 756 | + | } | |
| 757 | + | return out | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | // SetModelAvailability restricts a provider's visible catalog to the supplied | |
| 761 | + | // account model IDs. A nil slice clears the restriction; an empty non-nil slice | |
| 762 | + | // hides all of the provider's models. The snapshot is memory-only: it is never | |
| 763 | + | // written to the shared model cache because it is account-specific. | |
| 764 | + | func SetModelAvailability(provider string, ids []string) { | |
| 765 | + | activeMu.Lock() | |
| 766 | + | defer activeMu.Unlock() | |
| 767 | + | if ids == nil { | |
| 768 | + | delete(modelAvailability, provider) | |
| 769 | + | return | |
| 770 | + | } | |
| 771 | + | allowed := make(map[string]bool, len(ids)) | |
| 772 | + | for _, id := range ids { | |
| 773 | + | allowed[id] = true | |
| 774 | + | } | |
| 775 | + | modelAvailability[provider] = allowed | |
| 776 | + | } | |
| 777 | + | ||
| 778 | + | // filterAvailableModels removes models hidden by account availability. The | |
| 779 | + | // caller holds activeMu and owns models. | |
| 780 | + | func filterAvailableModels(models []Model) []Model { | |
| 781 | + | if len(modelAvailability) == 0 { | |
| 782 | + | return models | |
| 783 | + | } | |
| 784 | + | out := models[:0] | |
| 785 | + | for _, m := range models { | |
| 786 | + | allowed, restricted := modelAvailability[m.Provider] | |
| 787 | + | if !restricted || allowed[m.ID] { | |
| 788 | + | out = append(out, m) | |
| 789 | + | } | |
| 790 | + | } | |
| 791 | + | return out | |
| 723 | 792 | } | |
| 724 | 793 | ||
| 725 | 794 | // SetManagedModels replaces the ephemeral catalog entries supplied by local | |
| @@ -731,11 +800,32 @@ func SetManagedModels(models []Model) { | |||
| 731 | 800 | managedModels = append([]Model(nil), models...) | |
| 732 | 801 | } | |
| 733 | 802 | ||
| 803 | + | // SetManagedModelsForProvider replaces only one provider's managed entries, | |
| 804 | + | // leaving other managers' snapshots (llama.cpp, custom discovery, ...) intact. | |
| 805 | + | // Entries whose Provider differs from providerID are ignored. | |
| 806 | + | func SetManagedModelsForProvider(providerID string, models []Model) { | |
| 807 | + | activeMu.Lock() | |
| 808 | + | defer activeMu.Unlock() | |
| 809 | + | kept := make([]Model, 0, len(managedModels)+len(models)) | |
| 810 | + | for _, m := range managedModels { | |
| 811 | + | if m.Provider != providerID { | |
| 812 | + | kept = append(kept, m) | |
| 813 | + | } | |
| 814 | + | } | |
| 815 | + | for _, m := range models { | |
| 816 | + | if m.Provider == providerID { | |
| 817 | + | kept = append(kept, cloneModel(m)) | |
| 818 | + | } | |
| 819 | + | } | |
| 820 | + | managedModels = kept | |
| 821 | + | } | |
| 822 | + | ||
| 734 | 823 | // FindModel returns a Model by id, optionally constrained by provider. | |
| 735 | 824 | // If provider is empty, the first matching id is returned. Looks up | |
| 736 | - | // against the merged active catalog. | |
| 825 | + | // against the merged catalog, including models hidden by account availability: | |
| 826 | + | // existing sessions still need stable wire and pricing metadata after a refresh. | |
| 737 | 827 | func FindModel(provider, id string) (Model, error) { | |
| 738 | - | for _, m := range Active() { | |
| 828 | + | for _, m := range activeModels(false) { | |
| 739 | 829 | if m.ID == id && (provider == "" || m.Provider == provider) { | |
| 740 | 830 | return m, nil | |
| 741 | 831 | } | |
M packages/provider/openai.go +4 -1
| @@ -614,7 +614,10 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||
| 614 | 614 | } | |
| 615 | 615 | httpReq.Header.Set("content-type", "application/json") | |
| 616 | 616 | httpReq.Header.Set("accept", "text/event-stream") | |
| 617 | - | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | |
| 617 | + | if c.apiKey != "" { | |
| 618 | + | // Keyless custom endpoints send no Authorization header at all. | |
| 619 | + | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | |
| 620 | + | } | |
| 618 | 621 | for k, v := range c.headers { | |
| 619 | 622 | httpReq.Header.Set(k, v) | |
| 620 | 623 | } | |
M packages/provider/openai_responses.go +5 -1
| @@ -53,7 +53,11 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response, | |||
| 53 | 53 | if clone.Header.Get("x-opencode-client") != openCodeGoClient { | |
| 54 | 54 | clone.Header.Del("user-agent") | |
| 55 | 55 | } | |
| 56 | - | // Keep Authorization: Bearer <key> as set by the codex client. | |
| 56 | + | // The shared Responses client sets a Bearer header even with an empty key. | |
| 57 | + | // Keyless custom Responses endpoints need no Authorization header at all. | |
| 58 | + | if strings.TrimSpace(clone.Header.Get("authorization")) == "Bearer" { | |
| 59 | + | clone.Header.Del("authorization") | |
| 60 | + | } | |
| 57 | 61 | return t.inner.RoundTrip(clone) | |
| 58 | 62 | } | |
| 59 | 63 | ||
M packages/provider/openai_url_test.go +6 -2
| @@ -40,9 +40,13 @@ func TestChatCompletionsURL(t *testing.T) { | |||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | func TestGithubCopilotChatCompletionsURL(t *testing.T) { | |
| 43 | - | client, ok := NewGithubCopilotClient("test-token").(*openaiClient) | |
| 43 | + | copilot, ok := NewGithubCopilotClient("test-token").(*copilotClient) | |
| 44 | 44 | if !ok { | |
| 45 | - | t.Fatal("NewGithubCopilotClient did not return an openaiClient") | |
| 45 | + | t.Fatal("NewGithubCopilotClient did not return a copilotClient") | |
| 46 | + | } | |
| 47 | + | client, ok := copilot.router.fallback.(*openaiClient) | |
| 48 | + | if !ok { | |
| 49 | + | t.Fatal("GitHub Copilot router fallback is not an openaiClient") | |
| 46 | 50 | } | |
| 47 | 51 | ||
| 48 | 52 | const want = "https://api.individual.githubcopilot.com/chat/completions" | |
M packages/provider/provider.go +14 -17
| @@ -132,30 +132,27 @@ type ReasoningBlock struct { | |||
| 132 | 132 | func (ReasoningBlock) isContent() {} | |
| 133 | 133 | ||
| 134 | 134 | // RepairOrphanedToolResults removes invalid tool_result content blocks | |
| 135 | - | // and entire messages that become empty. A result is invalid when its | |
| 136 | - | // matching tool_use ID does not appear anywhere in the messages or when | |
| 137 | - | // an earlier result already covers the same ID. Resume tails, compaction | |
| 138 | - | // repair, and provider request builders all need this so the upstream API | |
| 139 | - | // sees exactly one result for each referenced tool call. | |
| 135 | + | // and entire messages that become empty. A result is invalid when there | |
| 136 | + | // is no preceding unmatched tool_use with the same ID in the messages. | |
| 137 | + | // Tool IDs normally are unique, but a provider can reuse one across turns. | |
| 138 | + | // Counting occurrences preserves each valid pair while still dropping | |
| 139 | + | // duplicate results. Resume tails, compaction repair, and provider request | |
| 140 | + | // builders all need this so the upstream API sees one result for each | |
| 141 | + | // referenced tool call. | |
| 140 | 142 | func RepairOrphanedToolResults(msgs []Message) []Message { | |
| 141 | - | useIDs := map[string]bool{} | |
| 142 | - | for _, m := range msgs { | |
| 143 | - | for _, c := range m.Content { | |
| 144 | - | if tc, ok := c.(ToolCallBlock); ok { | |
| 145 | - | useIDs[tc.ID] = true | |
| 146 | - | } | |
| 147 | - | } | |
| 148 | - | } | |
| 149 | - | resultIDs := map[string]bool{} | |
| 143 | + | openCalls := map[string]int{} | |
| 150 | 144 | out := make([]Message, 0, len(msgs)) | |
| 151 | 145 | for _, m := range msgs { | |
| 152 | 146 | var filtered []Content | |
| 153 | 147 | for _, c := range m.Content { | |
| 154 | - | if tr, ok := c.(ToolResultBlock); ok { | |
| 155 | - | if !useIDs[tr.CallID] || resultIDs[tr.CallID] { | |
| 148 | + | switch block := c.(type) { | |
| 149 | + | case ToolCallBlock: | |
| 150 | + | openCalls[block.ID]++ | |
| 151 | + | case ToolResultBlock: | |
| 152 | + | if openCalls[block.CallID] == 0 { | |
| 156 | 153 | continue | |
| 157 | 154 | } | |
| 158 | - | resultIDs[tr.CallID] = true | |
| 155 | + | openCalls[block.CallID]-- | |
| 159 | 156 | } | |
| 160 | 157 | filtered = append(filtered, c) | |
| 161 | 158 | } | |
M packages/provider/providers.go +9 -0
| @@ -54,6 +54,15 @@ var builtinProviderIDs = []string{ | |||
| 54 | 54 | ProviderCloudflareWorkersAI, ProviderCloudflareAIGateway, | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | + | func isBuiltinProviderID(id string) bool { | |
| 58 | + | for _, p := range builtinProviderIDs { | |
| 59 | + | if p == id { | |
| 60 | + | return true | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | ||
| 57 | 66 | // BuiltinProviderIDs returns the canonical built-in provider IDs in fallback | |
| 58 | 67 | // priority order. The returned slice is independent of package state. | |
| 59 | 68 | func BuiltinProviderIDs() []string { | |
A packages/provider/tool_result_pairing_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func pairingResult(id, text string) Message { | |
| 9 | + | return Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: id, Content: []Content{TextBlock{Text: text}}}}} | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | func pairingCall(id string) Message { | |
| 13 | + | return Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: id, Name: "read"}}} | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestRepairOrphanedToolResultsPreservesRepeatedToolCallPairs(t *testing.T) { | |
| 17 | + | msgs := []Message{pairingCall("reused"), pairingResult("reused", "first"), pairingCall("reused"), pairingResult("reused", "second")} | |
| 18 | + | got := RepairOrphanedToolResults(msgs) | |
| 19 | + | if !reflect.DeepEqual(got, msgs) { | |
| 20 | + | t.Fatalf("got %#v, want both repeated-ID pairs preserved", got) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestAnthropicBuildRequestPreservesRepeatedToolCallPairs(t *testing.T) { | |
| 25 | + | c := NewAnthropic("test-key", "").(*anthropicClient) | |
| 26 | + | wire, err := c.buildRequest(Request{ | |
| 27 | + | Model: "claude-sonnet-4-5", | |
| 28 | + | Messages: []Message{ | |
| 29 | + | pairingCall("reused"), pairingResult("reused", "first"), | |
| 30 | + | pairingCall("reused"), pairingResult("reused", "second"), | |
| 31 | + | }, | |
| 32 | + | }) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if len(wire.Messages) != 4 { | |
| 37 | + | t.Fatalf("messages=%d, want both repeated-ID tool result pairs", len(wire.Messages)) | |
| 38 | + | } | |
| 39 | + | for _, index := range []int{1, 3} { | |
| 40 | + | if len(wire.Messages[index].Content) != 1 { | |
| 41 | + | t.Fatalf("message %d content=%d, want 1 tool result", index, len(wire.Messages[index].Content)) | |
| 42 | + | } | |
| 43 | + | if _, ok := wire.Messages[index].Content[0].(anthToolResultBlock); !ok { | |
| 44 | + | t.Fatalf("message %d content type=%T, want anthToolResultBlock", index, wire.Messages[index].Content[0]) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestRepairOrphanedToolResultsDropsLeadingOrphanBeforeReusedID(t *testing.T) { | |
| 50 | + | user := Message{Role: RoleUser, Content: []Content{TextBlock{Text: "next turn"}}} | |
| 51 | + | messages := []Message{pairingResult("reused", "orphan"), user, pairingCall("reused"), pairingResult("reused", "valid")} | |
| 52 | + | want := messages[1:] | |
| 53 | + | if got := RepairOrphanedToolResults(messages); !reflect.DeepEqual(got, want) { | |
| 54 | + | t.Fatalf("got %#v, want leading orphan removed and later valid pair retained", got) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestRepairOrphanedToolResultsDropsExcessAndOrphans(t *testing.T) { | |
| 59 | + | msgs := []Message{ | |
| 60 | + | pairingCall("a"), pairingResult("a", "one"), pairingResult("a", "dup"), pairingResult("orphan", "x"), | |
| 61 | + | } | |
| 62 | + | got := RepairOrphanedToolResults(msgs) | |
| 63 | + | if len(got) != 2 { | |
| 64 | + | t.Fatalf("messages=%d, want 2", len(got)) | |
| 65 | + | } | |
| 66 | + | if tr := got[1].Content[0].(ToolResultBlock); tr.Content[0].(TextBlock).Text != "one" { | |
| 67 | + | t.Fatalf("kept wrong result: %#v", tr) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestRepairOrphanedToolResultsKeepsMixedTextAndDoesNotMutate(t *testing.T) { | |
| 72 | + | msgs := []Message{ | |
| 73 | + | pairingCall("a"), | |
| 74 | + | {Role: RoleTool, Content: []Content{ | |
| 75 | + | TextBlock{Text: "note"}, | |
| 76 | + | ToolResultBlock{CallID: "a"}, | |
| 77 | + | ToolResultBlock{CallID: "a"}, | |
| 78 | + | ToolResultBlock{CallID: "orphan"}, | |
| 79 | + | }}, | |
| 80 | + | } | |
| 81 | + | orig := []Message{msgs[0], {Role: RoleTool, Content: append([]Content(nil), msgs[1].Content...)}} | |
| 82 | + | got := RepairOrphanedToolResults(msgs) | |
| 83 | + | want := []Content{TextBlock{Text: "note"}, ToolResultBlock{CallID: "a"}} | |
| 84 | + | if len(got) != 2 || !reflect.DeepEqual(got[1].Content, want) { | |
| 85 | + | t.Fatalf("got %#v, want %#v", got, want) | |
| 86 | + | } | |
| 87 | + | if !reflect.DeepEqual(msgs, orig) { | |
| 88 | + | t.Fatalf("input mutated: %#v", msgs) | |
| 89 | + | } | |
| 90 | + | } | |
M packages/provider/usermodels.go +42 -6
| @@ -37,9 +37,14 @@ type UserModelsFile struct { | |||
| 37 | 37 | ||
| 38 | 38 | // UserProvider groups models under a provider key. | |
| 39 | 39 | type UserProvider struct { | |
| 40 | - | BaseURL string `json:"baseUrl,omitempty"` | |
| 41 | - | API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic" | |
| 42 | - | Models []UserModel `json:"models"` | |
| 40 | + | BaseURL string `json:"baseUrl,omitempty"` | |
| 41 | + | API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic" | |
| 42 | + | Auth string `json:"auth,omitempty"` // "none" disables credentials, including stored keys | |
| 43 | + | // Discover enables live model listing from the provider-level | |
| 44 | + | // baseUrl's /models endpoint. Off by default so existing custom | |
| 45 | + | // providers never gain unexpected network traffic. | |
| 46 | + | Discover bool `json:"discover,omitempty"` | |
| 47 | + | Models []UserModel `json:"models"` | |
| 43 | 48 | } | |
| 44 | 49 | ||
| 45 | 50 | // CustomProviderConfig holds runtime config for a user-defined provider | |
| @@ -47,6 +52,10 @@ type UserProvider struct { | |||
| 47 | 52 | type CustomProviderConfig struct { | |
| 48 | 53 | BaseURL string | |
| 49 | 54 | API string // "openai", "openai-responses", or "anthropic" | |
| 55 | + | NoAuth bool // explicitly configured for keyless access | |
| 56 | + | // Discover reports whether live model discovery is enabled. It is | |
| 57 | + | // only true when a provider-level BaseURL is present. | |
| 58 | + | Discover bool | |
| 50 | 59 | } | |
| 51 | 60 | ||
| 52 | 61 | var ( | |
| @@ -167,7 +176,30 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 167 | 176 | break | |
| 168 | 177 | } | |
| 169 | 178 | } | |
| 179 | + | // Discovery needs one endpoint to list from, so it is tied to the | |
| 180 | + | // provider-level baseUrl rather than per-model overrides. | |
| 181 | + | discover := prov.Discover | |
| 182 | + | if discover && strings.TrimSpace(prov.BaseURL) == "" { | |
| 183 | + | warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName)) | |
| 184 | + | discover = false | |
| 185 | + | } | |
| 186 | + | if prov.Auth != "" && prov.BaseURL == "" && !hasModelBaseURL { | |
| 187 | + | warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets auth without a baseUrl; ignored", providerName)) | |
| 188 | + | } | |
| 170 | 189 | if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL { | |
| 190 | + | auth := strings.ToLower(strings.TrimSpace(prov.Auth)) | |
| 191 | + | if auth != "" && auth != "none" { | |
| 192 | + | warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown auth %q; using credentials when available", providerName, prov.Auth)) | |
| 193 | + | } | |
| 194 | + | if prov.BaseURL == "" && !hasModelBaseURL { | |
| 195 | + | auth = "" | |
| 196 | + | } | |
| 197 | + | if auth == "none" && isBuiltinProviderID(normalized) { | |
| 198 | + | // Built-in providers keep their normal credential flow; a | |
| 199 | + | // models.json override must not weaken it. | |
| 200 | + | warnings = append(warnings, fmt.Sprintf("models.json: provider %q is a built-in provider; auth \"none\" ignored", providerName)) | |
| 201 | + | auth = "" | |
| 202 | + | } | |
| 171 | 203 | api := strings.ToLower(strings.TrimSpace(prov.API)) | |
| 172 | 204 | if api == "" { | |
| 173 | 205 | api = "openai" | |
| @@ -185,8 +217,10 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 185 | 217 | api = "openai" | |
| 186 | 218 | } | |
| 187 | 219 | customProviders[normalized] = CustomProviderConfig{ | |
| 188 | - | BaseURL: prov.BaseURL, | |
| 189 | - | API: api, | |
| 220 | + | BaseURL: prov.BaseURL, | |
| 221 | + | API: api, | |
| 222 | + | NoAuth: auth == "none", | |
| 223 | + | Discover: discover, | |
| 190 | 224 | } | |
| 191 | 225 | } | |
| 192 | 226 | ||
| @@ -217,7 +251,9 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 217 | 251 | modelBaseURL = prov.BaseURL | |
| 218 | 252 | } | |
| 219 | 253 | modelAPI := "" | |
| 220 | - | if cfg, ok := customProviders[normalized]; ok { | |
| 254 | + | if cfg, ok := customProviders[normalized]; ok && (prov.API != "" || !isBuiltinProviderID(normalized)) { | |
| 255 | + | // A built-in provider without an explicit api keeps its own | |
| 256 | + | // protocol routing; the openai default is for custom providers. | |
| 221 | 257 | modelAPI = cfg.API | |
| 222 | 258 | } | |
| 223 | 259 | m := Model{ | |
M packages/provider/usermodels_test.go +140 -0
| @@ -254,3 +254,143 @@ func TestUserPriceOverrideClearsCatalogTiers(t *testing.T) { | |||
| 254 | 254 | t.Fatalf("cost = %v, want user base price %v", got, want) | |
| 255 | 255 | } | |
| 256 | 256 | } | |
| 257 | + | ||
| 258 | + | func TestLoadUserModelsKeylessAuthRequiresEndpoint(t *testing.T) { | |
| 259 | + | path := filepath.Join(t.TempDir(), "models.json") | |
| 260 | + | if err := os.WriteFile(path, []byte(`{"providers":{ | |
| 261 | + | "keyless":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]}, | |
| 262 | + | "unknown":{"baseUrl":"http://localhost:9999/v1","auth":"typo","models":[{"id":"m"}]}, | |
| 263 | + | "no-url":{"auth":"none","models":[{"id":"m"}]} | |
| 264 | + | }}`), 0o644); err != nil { | |
| 265 | + | t.Fatal(err) | |
| 266 | + | } | |
| 267 | + | _, warnings := LoadUserModelsWithWarnings(path) | |
| 268 | + | if !CustomProviders()["keyless"].NoAuth || CustomProviders()["unknown"].NoAuth || CustomProviders()["no-url"].NoAuth { | |
| 269 | + | t.Fatalf("invalid keyless configuration: %+v", CustomProviders()) | |
| 270 | + | } | |
| 271 | + | if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "unknown auth") || !strings.Contains(strings.Join(warnings, "\n"), "without a baseUrl") { | |
| 272 | + | t.Fatalf("warnings = %v, want unknown auth and missing URL warnings", warnings) | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestLoadUserModelsDiscoverFlag(t *testing.T) { | |
| 277 | + | path := filepath.Join(t.TempDir(), "models.json") | |
| 278 | + | if err := os.WriteFile(path, []byte(`{ | |
| 279 | + | "providers": { | |
| 280 | + | "m4": {"baseUrl": "http://127.0.0.1:48000/v1", "discover": true}, | |
| 281 | + | "m5": {"baseUrl": "http://127.0.0.1:48001/v1", "models": [{"id": "pinned"}]}, | |
| 282 | + | "no-base": {"discover": true, "models": [{"id": "m1", "baseUrl": "http://127.0.0.1:48002/v1"}]} | |
| 283 | + | } | |
| 284 | + | }`), 0o644); err != nil { | |
| 285 | + | t.Fatal(err) | |
| 286 | + | } | |
| 287 | + | _, warnings := LoadUserModelsWithWarnings(path) | |
| 288 | + | if len(warnings) != 1 || !strings.Contains(warnings[0], `"no-base"`) || !strings.Contains(warnings[0], "discover") { | |
| 289 | + | t.Fatalf("warnings = %v, want one discover warning for no-base", warnings) | |
| 290 | + | } | |
| 291 | + | cps := CustomProviders() | |
| 292 | + | if !cps["m4"].Discover { | |
| 293 | + | t.Fatal("m4 discovery not enabled") | |
| 294 | + | } | |
| 295 | + | if cps["m5"].Discover { | |
| 296 | + | t.Fatal("m5 discovery enabled without flag") | |
| 297 | + | } | |
| 298 | + | if cfg, ok := cps["no-base"]; !ok || cfg.Discover { | |
| 299 | + | t.Fatalf("no-base = %+v, %v; want registered without discovery", cfg, ok) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func TestLoadUserModelsBuiltinProviderIgnoresAuthNone(t *testing.T) { | |
| 304 | + | path := filepath.Join(t.TempDir(), "models.json") | |
| 305 | + | if err := os.WriteFile(path, []byte(`{"providers":{ | |
| 306 | + | "anthropic":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]}, | |
| 307 | + | "local-x":{"baseUrl":"http://localhost:8889/v1","auth":"none","models":[{"id":"m"}]} | |
| 308 | + | }}`), 0o644); err != nil { | |
| 309 | + | t.Fatal(err) | |
| 310 | + | } | |
| 311 | + | _, warnings := LoadUserModelsWithWarnings(path) | |
| 312 | + | cps := CustomProviders() | |
| 313 | + | if cps["anthropic"].NoAuth { | |
| 314 | + | t.Fatal("built-in provider accepted auth none") | |
| 315 | + | } | |
| 316 | + | if !cps["local-x"].NoAuth { | |
| 317 | + | t.Fatal("custom provider lost auth none") | |
| 318 | + | } | |
| 319 | + | if len(warnings) != 1 || !strings.Contains(warnings[0], `"anthropic"`) || !strings.Contains(warnings[0], "built-in") { | |
| 320 | + | t.Fatalf("warnings = %v", warnings) | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func TestLoadUserModelsBuiltinBaseURLOnlyKeepsProtocolDefaults(t *testing.T) { | |
| 325 | + | SetLiveModels(nil) | |
| 326 | + | SetUserModels(nil) | |
| 327 | + | t.Cleanup(func() { | |
| 328 | + | SetLiveModels(nil) | |
| 329 | + | SetUserModels(nil) | |
| 330 | + | LoadUserModelsWithWarnings(filepath.Join(t.TempDir(), "missing.json")) | |
| 331 | + | }) | |
| 332 | + | path := filepath.Join(t.TempDir(), "models.json") | |
| 333 | + | if err := os.WriteFile(path, []byte(`{"providers":{ | |
| 334 | + | "github-copilot":{"baseUrl":"https://copilot.proxy.example","models":[ | |
| 335 | + | {"id":"claude-opus-4.7","reasoning":true},{"id":"gpt-5.5","reasoning":true},{"id":"proxy-only"}]}, | |
| 336 | + | "company-proxy":{"baseUrl":"https://llm.example.com/v1","models":[{"id":"m"}]} | |
| 337 | + | }}`), 0o644); err != nil { | |
| 338 | + | t.Fatal(err) | |
| 339 | + | } | |
| 340 | + | models, warnings := LoadUserModelsWithWarnings(path) | |
| 341 | + | if len(warnings) != 0 { | |
| 342 | + | t.Fatalf("warnings = %v", warnings) | |
| 343 | + | } | |
| 344 | + | for _, m := range models { | |
| 345 | + | switch m.Provider + "/" + m.ID { | |
| 346 | + | case "github-copilot/claude-opus-4.7", "github-copilot/gpt-5.5", "github-copilot/proxy-only": | |
| 347 | + | if m.API != "" { | |
| 348 | + | t.Errorf("%s/%s API = %q, want empty so built-in routing applies", m.Provider, m.ID, m.API) | |
| 349 | + | } | |
| 350 | + | case "company-proxy/m": | |
| 351 | + | if m.API != "openai" { | |
| 352 | + | t.Errorf("custom provider API = %q, want openai default", m.API) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | SetUserModels(models) | |
| 357 | + | for id, want := range map[string]string{"claude-opus-4.7": APIAnthropicMessages, "gpt-5.5": APIResponses, "proxy-only": APICompletions} { | |
| 358 | + | got, err := FindModel("github-copilot", id) | |
| 359 | + | if err != nil { | |
| 360 | + | t.Fatal(err) | |
| 361 | + | } | |
| 362 | + | if got.API != want || got.BaseURL != "https://copilot.proxy.example" { | |
| 363 | + | t.Errorf("%s API=%q baseURL=%q, want API %q with override URL", id, got.API, got.BaseURL, want) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func TestLoadUserModelsBuiltinExplicitAPIOverridesDefaults(t *testing.T) { | |
| 369 | + | SetLiveModels(nil) | |
| 370 | + | SetUserModels(nil) | |
| 371 | + | t.Cleanup(func() { | |
| 372 | + | SetLiveModels(nil) | |
| 373 | + | SetUserModels(nil) | |
| 374 | + | LoadUserModelsWithWarnings(filepath.Join(t.TempDir(), "missing.json")) | |
| 375 | + | }) | |
| 376 | + | path := filepath.Join(t.TempDir(), "models.json") | |
| 377 | + | if err := os.WriteFile(path, []byte(`{"providers":{ | |
| 378 | + | "github-copilot":{"baseUrl":"https://copilot.proxy.example","api":"openai","models":[{"id":"claude-opus-4.7","reasoning":true},{"id":"gpt-5.5","reasoning":true}]} | |
| 379 | + | }}`), 0o644); err != nil { | |
| 380 | + | t.Fatal(err) | |
| 381 | + | } | |
| 382 | + | models, warnings := LoadUserModelsWithWarnings(path) | |
| 383 | + | if len(warnings) != 0 { | |
| 384 | + | t.Fatalf("warnings = %v", warnings) | |
| 385 | + | } | |
| 386 | + | SetUserModels(models) | |
| 387 | + | for _, id := range []string{"claude-opus-4.7", "gpt-5.5"} { | |
| 388 | + | got, err := FindModel("github-copilot", id) | |
| 389 | + | if err != nil { | |
| 390 | + | t.Fatal(err) | |
| 391 | + | } | |
| 392 | + | if got.API != APICompletions { | |
| 393 | + | t.Errorf("%s API = %q, want explicit openai alias mapped to %q", id, got.API, APICompletions) | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | } | |
M packages/tui/highlight.go +40 -11
| @@ -251,13 +251,18 @@ func LanguageFromPath(p string) string { | |||
| 251 | 251 | return extLang[ext] | |
| 252 | 252 | } | |
| 253 | 253 | ||
| 254 | - | // chooseLexer picks the best lexer for a language hint; falls back to | |
| 255 | - | // nil (no highlighting) if the hint is empty or unknown. | |
| 256 | 254 | // stripANSIBackgrounds removes SGR background-color attributes emitted by | |
| 257 | 255 | // terminal syntax formatters while preserving foreground colors and styles. | |
| 258 | 256 | // Chroma's inherited styles can assign black backgrounds to a few tokens | |
| 259 | 257 | // (notably punctuation/error-ish spans), which looks like random black blocks | |
| 260 | 258 | // inside zut's dark-gray tool boxes. Foreground color is enough for code. | |
| 259 | + | // | |
| 260 | + | // Extended color parameters ("38;5;N", "38;2;R;G;B" and their "48;..." | |
| 261 | + | // background twins) are consumed as whole units. The color index of an | |
| 262 | + | // extended foreground is a value, not a standalone ANSI code: stripping a | |
| 263 | + | // "45" or "101" out of "\x1b[38;5;45m" leaves the malformed "\x1b[38;5m", | |
| 264 | + | // whose leftover "5" terminals read as blink (SGR 5). A truncated extended | |
| 265 | + | // color cannot be rendered anyway, so it is dropped rather than forwarded. | |
| 261 | 266 | func stripANSIBackgrounds(s string) string { | |
| 262 | 267 | var out strings.Builder | |
| 263 | 268 | out.Grow(len(s)) | |
| @@ -280,16 +285,14 @@ func stripANSIBackgrounds(s string) string { | |||
| 280 | 285 | for p := 0; p < len(params); p++ { | |
| 281 | 286 | param := params[p] | |
| 282 | 287 | switch param { | |
| 283 | - | case "48": | |
| 284 | - | // 48;5;N or 48;2;R;G;B background color. | |
| 285 | - | if p+1 < len(params) && params[p+1] == "5" { | |
| 286 | - | p += 2 | |
| 287 | - | continue | |
| 288 | - | } | |
| 289 | - | if p+1 < len(params) && params[p+1] == "2" { | |
| 290 | - | p += 4 | |
| 291 | - | continue | |
| 288 | + | case "38", "48": | |
| 289 | + | span, complete := extendedColorSpan(params, p) | |
| 290 | + | if complete && param == "38" { | |
| 291 | + | kept = append(kept, span...) | |
| 292 | 292 | } | |
| 293 | + | // Consume the whole unit either way: a truncated | |
| 294 | + | // "38;5" must not leave its "5" to be read as blink. | |
| 295 | + | p += len(span) - 1 | |
| 293 | 296 | continue | |
| 294 | 297 | case "49": | |
| 295 | 298 | continue | |
| @@ -313,6 +316,32 @@ func stripANSIBackgrounds(s string) string { | |||
| 313 | 316 | return out.String() | |
| 314 | 317 | } | |
| 315 | 318 | ||
| 319 | + | // extendedColorSpan returns the parameter run that belongs to the extended | |
| 320 | + | // color starting at params[i] ("38" or "48"): "5;N" or "2;R;G;B". complete | |
| 321 | + | // reports whether the run can be rendered; a truncated run is still returned | |
| 322 | + | // so its parameters are consumed instead of being reprocessed as standalone | |
| 323 | + | // codes (a bare "5" would mean blink). | |
| 324 | + | func extendedColorSpan(params []string, i int) (span []string, complete bool) { | |
| 325 | + | if i+1 >= len(params) { | |
| 326 | + | return params[i : i+1], false | |
| 327 | + | } | |
| 328 | + | switch params[i+1] { | |
| 329 | + | case "5": | |
| 330 | + | if i+2 >= len(params) { | |
| 331 | + | return params[i : i+2], false | |
| 332 | + | } | |
| 333 | + | return params[i : i+3], true | |
| 334 | + | case "2": | |
| 335 | + | if i+5 > len(params) { | |
| 336 | + | return params[i:], false | |
| 337 | + | } | |
| 338 | + | return params[i : i+5], true | |
| 339 | + | } | |
| 340 | + | return params[i : i+1], false | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // chooseLexer picks the best lexer for a language hint; falls back to | |
| 344 | + | // nil (no highlighting) if the hint is empty or unknown. | |
| 316 | 345 | func chooseLexer(lang string) chroma.Lexer { | |
| 317 | 346 | if lang == "" { | |
| 318 | 347 | return nil | |
A packages/tui/highlight_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestStripANSIBackgroundsKeepsExtendedColors(t *testing.T) { | |
| 10 | + | cases := []struct{ name, in, want string }{ | |
| 11 | + | {"256 foreground in background range", "\x1b[38;5;45mstring\x1b[0m", "\x1b[38;5;45mstring\x1b[0m"}, | |
| 12 | + | {"256 foreground index 48", "\x1b[38;5;48mx\x1b[0m", "\x1b[38;5;48mx\x1b[0m"}, | |
| 13 | + | {"256 foreground index 49", "\x1b[38;5;49mx\x1b[0m", "\x1b[38;5;49mx\x1b[0m"}, | |
| 14 | + | {"256 foreground bright range", "\x1b[38;5;101mcomment\x1b[0m", "\x1b[38;5;101mcomment\x1b[0m"}, | |
| 15 | + | {"256 foreground index 107", "\x1b[38;5;107mx\x1b[0m", "\x1b[38;5;107mx\x1b[0m"}, | |
| 16 | + | {"bold plus foreground", "\x1b[1;38;5;45mstring\x1b[0m", "\x1b[1;38;5;45mstring\x1b[0m"}, | |
| 17 | + | {"truecolor foreground", "\x1b[38;2;40;101;49mstring\x1b[0m", "\x1b[38;2;40;101;49mstring\x1b[0m"}, | |
| 18 | + | {"plain text", "hello", "hello"}, | |
| 19 | + | {"reset", "\x1b[0m", "\x1b[0m"}, | |
| 20 | + | {"256 background dropped", "\x1b[48;5;45mbg\x1b[0m", "bg\x1b[0m"}, | |
| 21 | + | {"separate background dropped", "\x1b[38;5;45m\x1b[48;5;0mtext", "\x1b[38;5;45mtext"}, | |
| 22 | + | {"combined background dropped", "\x1b[38;5;45;48;5;0mtext", "\x1b[38;5;45mtext"}, | |
| 23 | + | {"truecolor background dropped", "\x1b[48;2;9;0;21mbg\x1b[0m", "bg\x1b[0m"}, | |
| 24 | + | {"default background dropped", "\x1b[49mbg", "bg"}, | |
| 25 | + | {"ANSI background dropped", "\x1b[41mbg", "bg"}, | |
| 26 | + | {"bright background dropped", "\x1b[101mbg", "bg"}, | |
| 27 | + | {"bare foreground introducer dropped", "\x1b[38mtext", "text"}, | |
| 28 | + | {"truncated indexed foreground", "\x1b[38;5mstring", "string"}, | |
| 29 | + | {"truncated indexed background", "\x1b[48;5mstring", "string"}, | |
| 30 | + | {"truncated truecolor foreground", "\x1b[38;2;1;2mtext", "text"}, | |
| 31 | + | {"truncated truecolor background", "\x1b[48;2;1;2mtext", "text"}, | |
| 32 | + | // An unsupported extended selector is not consumed as a color unit; | |
| 33 | + | // unrelated standalone attributes keep their ordinary SGR semantics. | |
| 34 | + | {"unknown selector preserves reverse", "\x1b[38;7mtext", "\x1b[7mtext"}, | |
| 35 | + | } | |
| 36 | + | for _, tc := range cases { | |
| 37 | + | t.Run(tc.name, func(t *testing.T) { | |
| 38 | + | if got := stripANSIBackgrounds(tc.in); got != tc.want { | |
| 39 | + | t.Fatalf("got %q, want %q", got, tc.want) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Chroma maps this custom type color to xterm index 45, which the old | |
| 46 | + | // background filter removed from the foreground unit. | |
| 47 | + | func TestHighlightCodeExtendedColorIsWellFormed(t *testing.T) { | |
| 48 | + | th := Dark | |
| 49 | + | th.Syntax = SyntaxTheme{KeywordType: "#00e5ff", NameBuiltin: "#00e5ff"} | |
| 50 | + | joined := strings.Join(th.HighlightCode("func splitFrontmatter(raw string) (string, string) {\n", "go"), "\n") | |
| 51 | + | if strings.Contains(joined, "\x1b[38;5m") || strings.Contains(joined, "\x1b[48;5m") { | |
| 52 | + | t.Fatalf("highlighting emitted a malformed extended color: %q", joined) | |
| 53 | + | } | |
| 54 | + | idx := strings.Index(joined, "string") | |
| 55 | + | if idx < 0 { | |
| 56 | + | t.Fatalf("type keyword missing: %q", joined) | |
| 57 | + | } | |
| 58 | + | prefix := joined[:idx] | |
| 59 | + | escape := strings.LastIndex(prefix, "\x1b[") | |
| 60 | + | if escape < 0 || !regexp.MustCompile(`^\x1b\[(?:[0-9]+;)*38;5;[0-9]+m$`).MatchString(prefix[escape:]) { | |
| 61 | + | t.Fatalf("type keyword lost its foreground color: %q", joined) | |
| 62 | + | } | |
| 63 | + | } | |
M packages/tui/input.go +9 -0
| @@ -275,6 +275,15 @@ func (r *Reader) readEscape() (Key, error) { | |||
| 275 | 275 | return Key{}, err | |
| 276 | 276 | } | |
| 277 | 277 | switch c { | |
| 278 | + | // Terminals in application cursor mode send arrows as SS3. | |
| 279 | + | case 'A': | |
| 280 | + | return Key{Kind: KeyUp}, nil | |
| 281 | + | case 'B': | |
| 282 | + | return Key{Kind: KeyDown}, nil | |
| 283 | + | case 'C': | |
| 284 | + | return Key{Kind: KeyRight}, nil | |
| 285 | + | case 'D': | |
| 286 | + | return Key{Kind: KeyLeft}, nil | |
| 278 | 287 | case 'H': | |
| 279 | 288 | return Key{Kind: KeyHome}, nil | |
| 280 | 289 | case 'F': | |
M packages/tui/input_test.go +19 -0
| @@ -243,6 +243,25 @@ func TestReaderWithoutPeekDoesNotBlockBareEscapeOrStartAnotherPump(t *testing.T) | |||
| 243 | 243 | } | |
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | + | func TestReaderParsesSS3CursorKeys(t *testing.T) { | |
| 247 | + | cases := []struct { | |
| 248 | + | seq string | |
| 249 | + | want KeyKind | |
| 250 | + | }{ | |
| 251 | + | {"\x1bOA", KeyUp}, | |
| 252 | + | {"\x1bOB", KeyDown}, | |
| 253 | + | {"\x1bOC", KeyRight}, | |
| 254 | + | {"\x1bOD", KeyLeft}, | |
| 255 | + | {"\x1bOH", KeyHome}, | |
| 256 | + | {"\x1bOF", KeyEnd}, | |
| 257 | + | } | |
| 258 | + | for _, tc := range cases { | |
| 259 | + | if k := readKey(t, tc.seq); k.Kind != tc.want { | |
| 260 | + | t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 246 | 265 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | |
| 247 | 266 | cases := []struct { | |
| 248 | 267 | seq string | |
A packages/tui/pinned_skills.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/mattn/go-runewidth" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // RenderPinnedSkills matches the startup resource sections, with an accent | |
| 10 | + | // heading and indented, muted names. A leading blank separates it from chat. | |
| 11 | + | func RenderPinnedSkills(th Theme, names []string, width int) []string { | |
| 12 | + | if len(names) == 0 || width < 4 { | |
| 13 | + | return nil | |
| 14 | + | } | |
| 15 | + | out := []string{"", th.FGColor(th.Accent, runewidth.Truncate("[Pinned skills]", width, "..."))} | |
| 16 | + | for _, line := range wrapLine(strings.Join(names, ", "), width-2, "") { | |
| 17 | + | out = append(out, th.FGColor(th.Muted, " "+line)) | |
| 18 | + | } | |
| 19 | + | return out | |
| 20 | + | } | |
A packages/tui/pinned_skills_test.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPinnedSkillsMatchesResourceSections(t *testing.T) { | |
| 10 | + | th := Theme{Accent: Color256(75), Muted: Color256(245)} | |
| 11 | + | got := RenderPinnedSkills(th, []string{"review", "vscode"}, 80) | |
| 12 | + | want := []string{"", th.FGColor(th.Accent, "[Pinned skills]"), th.FGColor(th.Muted, " review, vscode")} | |
| 13 | + | if !reflect.DeepEqual(got, want) { | |
| 14 | + | t.Fatalf("rows = %q, want %q", got, want) | |
| 15 | + | } | |
| 16 | + | if got := RenderPinnedSkills(th, nil, 80); len(got) != 0 { | |
| 17 | + | t.Fatalf("empty pins rendered rows: %q", got) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestPinnedSkillsWrapNamesToTerminalWidth(t *testing.T) { | |
| 22 | + | for _, width := range []int{1, 4, 12, 30, 80} { | |
| 23 | + | rows := RenderPinnedSkills(Theme{}, []string{strings.Repeat("界", 20), "review", "vscode"}, width) | |
| 24 | + | for _, row := range rows { | |
| 25 | + | if got := visibleWidth(row); got > width { | |
| 26 | + | t.Fatalf("row width %d exceeds %d: %q", got, width, row) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | if width >= 4 { | |
| 30 | + | body := strings.Join(rows[2:], "") | |
| 31 | + | if !strings.Contains(body, "vscode") && width >= 12 { | |
| 32 | + | t.Fatalf("last skill was truncated: %q", rows) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |