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 | - 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. | 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 | - three run modes (interactive tui, print, json). | 13 | - three run modes (interactive tui, print, json). | ||
| 14 | - built-in telegram bot. | 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 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | 16 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | ||
| 17 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | 17 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | ||
| 18 | - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md). | 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 | - **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. | 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 | - **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. | 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 | - 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. | 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 | - 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 | - 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 | - 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 | - 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 | - 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 | - 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 | > **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 | > **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 | ### `/session` | 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 | - **`/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. | 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 | **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. | 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 | ### `/skills` | 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 | ### `/compact` | 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 | To retrieve this custom provider's key from a password manager, add a matching entry to `$ZUT_HOME/auth.json`: | 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 | "capabilities":["commands","tools","alerts","panels"]} | 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 | #### `register_command` | 209 | #### `register_command` | ||
| 204 | 210 | ||||
| 205 | ```json | 211 | ```json | ||
| @@ -242,6 +248,66 @@ Set `"deferred": true` to register a tool without advertising its definition ini | |||||
| 242 | 248 | ||||
| 243 | 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. | 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 | #### `ready` | 311 | #### `ready` | ||
| 246 | 312 | ||||
| 247 | Sentinel telling zut "all initial registrations are flushed". Send it | 313 | Sentinel telling zut "all initial registrations are flushed". Send it | ||
| @@ -541,7 +607,42 @@ responsible for validating/coercing it. | |||||
| 541 | 607 | ||||
| 542 | Reply with `tool_result` within the host's tool timeout (default 60s). | 608 | Reply with `tool_result` within the host's tool timeout (default 60s). | ||
| 543 | Missing the timeout surfaces an error to the model and the call is | 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 | #### `event` | 647 | #### `event` | ||
| 547 | 648 | ||||
M docs/providers.md +92 -5
| @@ -90,6 +90,38 @@ These providers support subscription login: | |||||
| 90 | OAuth tokens are stored in `$ZUT_HOME/auth.json` and refreshed when refresh is | 90 | OAuth tokens are stored in `$ZUT_HOME/auth.json` and refreshed when refresh is | ||
| 91 | available. | 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 | ## API-key providers | 125 | ## API-key providers | ||
| 94 | 126 | ||||
| 95 | These providers can use environment variables. Simple API-key providers can | 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 | Custom providers are first-class: they appear in `--list-models`, `/model`, and | 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`, | 513 | + | `/login`. `models.json` never stores secrets. For authenticated endpoints, supply |
| 482 | - | `--api-key`, or a derived environment variable in upper snake case, so | 514 | + | the key through `/login`, `--api-key`, or a derived environment variable in upper |
| 483 | - | `my-company` reads `MY_COMPANY_API_KEY`. Because many self-hosted gateways do | 515 | + | snake case, so `my-company` reads `MY_COMPANY_API_KEY`. Custom endpoints with a |
| 484 | - | not expose a model-list endpoint, custom provider keys are accepted and stored | 516 | + | configured base URL can also run without a key. Set `"auth": "none"` on a custom |
| 485 | - | without a verification probe; an invalid key surfaces on the first model call. | 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 | To retrieve this custom provider's key from a password manager, add a matching | 526 | To retrieve this custom provider's key from a password manager, add a matching | ||
| 488 | entry to `$ZUT_HOME/auth.json`: | 527 | entry to `$ZUT_HOME/auth.json`: | ||
| @@ -508,6 +547,54 @@ custom model directly: | |||||
| 508 | zut --provider my-company --model company-llm-v2 | 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 | ## Credential resolution | 598 | ## Credential resolution | ||
| 512 | 599 | ||||
| 513 | For each request, zut checks credentials in this order: | 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 | bundled skills; bundled skills take precedence over embedded built-ins. When | 79 | bundled skills; bundled skills take precedence over embedded built-ins. When | ||
| 80 | two extensions provide the same skill name, the first declaration wins. The | 80 | two extensions provide the same skill name, the first declaration wins. The | ||
| 81 | host rejects manifest paths that escape the extension directory, including | 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 | When `XDG_STATE_HOME` is set on any platform, `$ZUT_HOME` defaults to | 103 | When `XDG_STATE_HOME` is set on any platform, `$ZUT_HOME` defaults to | ||
| 85 | `$XDG_STATE_HOME/zut`. Otherwise it defaults to `~/Library/Application Support/zut/` | 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 | description and source path. Press enter on a row to view the full | 110 | description and source path. Press enter on a row to view the full | ||
| 92 | body inline. Press esc to go back. | 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 | ## Invoking skills | 193 | ## Invoking skills | ||
| 95 | 194 | ||||
| 96 | For normal skills, the system prompt tells the model the skill names and | 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 | defer closeResolvedLSP(next) | 281 | defer closeResolvedLSP(next) | ||
| 282 | agent.Client = next.NewClient() | 282 | agent.Client = next.NewClient() | ||
| 283 | - | agent.Model = next.Model | 283 | + | applyResolvedModel(agent, next) |
| 284 | runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD) | 284 | runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD) | ||
| 285 | return nil | 285 | return nil | ||
| 286 | }, | 286 | }, | ||
| @@ -532,3 +532,13 @@ func telegramBotReset() error { | |||||
| 532 | fmt.Println("removed", p) | 532 | fmt.Println("removed", p) | ||
| 533 | return nil | 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 | "sort" | 9 | "sort" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | "sync" | 11 | "sync" | ||
| 12 | + | "time" | |||
| 12 | 13 | ||||
| 13 | zutdocs "github.com/bnema/zut" | 14 | zutdocs "github.com/bnema/zut" | ||
| 14 | "github.com/bnema/zut/packages/agent/lsp" | 15 | "github.com/bnema/zut/packages/agent/lsp" | ||
| @@ -69,6 +70,11 @@ type Resolved struct { | |||||
| 69 | // client construction does not reread a mutable global registry. | 70 | // client construction does not reread a mutable global registry. | ||
| 70 | customProviderConfig *provider.CustomProviderConfig | 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 | // SkillTool is the on-demand skill loader registered with the | 78 | // SkillTool is the on-demand skill loader registered with the | ||
| 73 | // agent's tool registry, or nil if no SKILL.md files were | 79 | // agent's tool registry, or nil if no SKILL.md files were | ||
| 74 | // discovered. Exposed so the tui can list / preview skills. | 80 | // discovered. Exposed so the tui can list / preview skills. | ||
| @@ -93,8 +99,11 @@ type Resolved struct { | |||||
| 93 | toolDescriptions map[string]string | 99 | toolDescriptions map[string]string | ||
| 94 | } | 100 | } | ||
| 95 | 101 | ||||
| 96 | - | // HasCredential reports whether a credential was resolved. | 102 | + | // HasCredential reports whether the resolved configuration can create a client. |
| 97 | - | func (r Resolved) HasCredential() bool { return r.Credential != "" } | 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 | // ModelCatalogSnapshot returns the runtime-owned model metadata captured while | 108 | // ModelCatalogSnapshot returns the runtime-owned model metadata captured while | ||
| 100 | // resolving. It is used by SDK clients whose provider catalog is scoped to a | 109 | // resolving. It is used by SDK clients whose provider catalog is scoped to a | ||
| @@ -235,6 +244,7 @@ type ExtensionToolInfo struct { | |||||
| 235 | Description string | 244 | Description string | ||
| 236 | Schema []byte | 245 | Schema []byte | ||
| 237 | Deferred bool | 246 | Deferred bool | ||
| 247 | + | Interactive bool | |||
| 238 | } | 248 | } | ||
| 239 | 249 | ||||
| 240 | // toolSummariesFromRegistry rebuilds the system-prompt tool list | 250 | // toolSummariesFromRegistry rebuilds the system-prompt tool list | ||
| @@ -343,7 +353,7 @@ func defaultModelForProvider(prov string) string { | |||||
| 343 | case provider.ProviderAzureOpenAIResponses: | 353 | case provider.ProviderAzureOpenAIResponses: | ||
| 344 | return "gpt-5" | 354 | return "gpt-5" | ||
| 345 | case provider.ProviderGitHubCopilot: | 355 | case provider.ProviderGitHubCopilot: | ||
| 346 | - | return "claude-sonnet-4.5" | 356 | + | return "claude-sonnet-5" |
| 347 | default: | 357 | default: | ||
| 348 | // Custom providers: pick the first model from the catalog for | 358 | // Custom providers: pick the first model from the catalog for | ||
| 349 | // that provider, or fall back to the global default. | 359 | // that provider, or fall back to the global default. | ||
| @@ -590,6 +600,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 590 | repairConfig = true | 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 | var ( | 608 | var ( | ||
| 594 | cred string | 609 | cred string | ||
| 595 | method string | 610 | method string | ||
| @@ -601,7 +616,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 601 | // "ollama" credential never triggers the cloud default. | 616 | // "ollama" credential never triggers the cloud default. | ||
| 602 | hasRealOllamaCred bool | 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 | cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) | 624 | cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) | ||
| 606 | if credErr != nil { | 625 | if credErr != nil { | ||
| 607 | // Local ollama needs no key: fall back to the dummy | 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 | // Persist --api-key for custom providers so subsequent runs don't | 636 | // Persist --api-key for custom providers so subsequent runs don't | ||
| 618 | // need to pass it again. | 637 | // need to pass it again. | ||
| 619 | - | if !isBuiltinProvider(provName) && args.APIKey != "" { | 638 | + | if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" { |
| 620 | if store := AuthStoreFor(); store != nil { | 639 | if store := AuthStoreFor(); store != nil { | ||
| 621 | _ = store.SetAPIKey(provName, args.APIKey) | 640 | _ = store.SetAPIKey(provName, args.APIKey) | ||
| 622 | } | 641 | } | ||
| @@ -707,6 +726,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 707 | model = defaultModelForProvider(provName) | 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 | resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog) | 740 | resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog) | ||
| 711 | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) { | 741 | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) { | ||
| 712 | // Local providers are intentionally open-catalogue: any model id the | 742 | // Local providers are intentionally open-catalogue: any model id the | ||
| @@ -820,7 +850,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 820 | _ = SaveConfig(cfg) | 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 | // If the model defines a base URL (e.g. local ollama) and the | 858 | // If the model defines a base URL (e.g. local ollama) and the | ||
| 826 | // user didn't pass --base-url, use the model's URL. For ollama, | 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 | // If the model has a base URL, credentials are optional (local | 893 | // If the model has a base URL, credentials are optional (local | ||
| 861 | // models like ollama don't need real API keys). | 894 | // models like ollama don't need real API keys). | ||
| 862 | - | if resolvedModel.BaseURL != "" && credErr != nil { | 895 | + | credentialOptional := false |
| 863 | - | cred = "ollama" | 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 | credErr = nil | 910 | credErr = nil | ||
| 865 | requireCred = false | 911 | requireCred = false | ||
| 866 | } | 912 | } | ||
| @@ -1013,6 +1059,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 1013 | modelCatalog: modelCatalog, | 1059 | modelCatalog: modelCatalog, | ||
| 1014 | modelCatalogAuthoritative: args.modelCatalogAuthoritative, | 1060 | modelCatalogAuthoritative: args.modelCatalogAuthoritative, | ||
| 1015 | customProviderConfig: customProviderConfig, | 1061 | customProviderConfig: customProviderConfig, | ||
| 1062 | + | credentialOptional: credentialOptional, | |||
| 1016 | Sandbox: sandbox, | 1063 | Sandbox: sandbox, | ||
| 1017 | SkillTool: skillTool, | 1064 | SkillTool: skillTool, | ||
| 1018 | skillsEnabled: skillsEnabled, | 1065 | skillsEnabled: skillsEnabled, | ||
| @@ -1133,7 +1180,7 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string { | |||||
| 1133 | // must check HasCredential() first. | 1180 | // must check HasCredential() first. | ||
| 1134 | func (r Resolved) NewClient() provider.Client { | 1181 | func (r Resolved) NewClient() provider.Client { | ||
| 1135 | if !r.HasCredential() { | 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 | wrap := r.withHTTPClient | 1185 | wrap := r.withHTTPClient | ||
| 1139 | switch r.Provider { | 1186 | switch r.Provider { | ||
M packages/agent/cli.go +48 -145
| @@ -73,6 +73,8 @@ func (h *interactiveExtHooks) iv() *modes.Interactive { | |||||
| 73 | return h.interactive | 73 | return h.interactive | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | func (h *interactiveExtHooks) SupportsInteractiveTools() bool { return h.iv() != nil } | |||
| 77 | + | ||||
| 76 | func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) { | 78 | func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) { | ||
| 77 | if h == nil || iv == nil { | 79 | if h == nil || iv == nil { | ||
| 78 | return | 80 | return | ||
| @@ -272,6 +274,7 @@ func (a *extToolAdapter) Tools() []ExtensionToolInfo { | |||||
| 272 | Description: t.Description, | 274 | Description: t.Description, | ||
| 273 | Schema: t.Schema, | 275 | Schema: t.Schema, | ||
| 274 | Deferred: t.Deferred, | 276 | Deferred: t.Deferred, | ||
| 277 | + | Interactive: t.Interactive, | |||
| 275 | } | 278 | } | ||
| 276 | } | 279 | } | ||
| 277 | return out | 280 | return out | ||
| @@ -284,6 +287,7 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool { | |||||
| 284 | Description: info.Description, | 287 | Description: info.Description, | ||
| 285 | Schema: info.Schema, | 288 | Schema: info.Schema, | ||
| 286 | Deferred: info.Deferred, | 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 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 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 | if err != nil { | 1154 | if err != nil { | ||
| 1151 | return err | 1155 | return err | ||
| 1152 | } | 1156 | } | ||
| @@ -1184,6 +1188,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||||
| 1184 | persistCompaction = sess.AppendCompaction | 1188 | persistCompaction = sess.AppendCompaction | ||
| 1185 | } | 1189 | } | ||
| 1186 | started := time.Now() | 1190 | started := time.Now() | ||
| 1191 | + | prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr) | |||
| 1187 | usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) | 1192 | usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) | ||
| 1188 | elapsed := time.Since(started) | 1193 | elapsed := time.Since(started) | ||
| 1189 | transcriptStart := start | 1194 | transcriptStart := start | ||
| @@ -1228,7 +1233,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||||
| 1228 | } | 1233 | } | ||
| 1229 | }() | 1234 | }() | ||
| 1230 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 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 | if err != nil { | 1237 | if err != nil { | ||
| 1233 | return err | 1238 | return err | ||
| 1234 | } | 1239 | } | ||
| @@ -1268,6 +1273,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||||
| 1268 | if sess != nil { | 1273 | if sess != nil { | ||
| 1269 | persistCompaction = sess.AppendCompaction | 1274 | persistCompaction = sess.AppendCompaction | ||
| 1270 | } | 1275 | } | ||
| 1276 | + | prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr) | |||
| 1271 | recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction) | 1277 | recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction) | ||
| 1272 | transcriptStart := start | 1278 | transcriptStart := start | ||
| 1273 | if recovery.Compacted { | 1279 | if recovery.Compacted { | ||
| @@ -1349,7 +1355,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||||
| 1349 | } | 1355 | } | ||
| 1350 | }() | 1356 | }() | ||
| 1351 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 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 | if err != nil { | 1359 | if err != nil { | ||
| 1354 | return err | 1360 | return err | ||
| 1355 | } | 1361 | } | ||
| @@ -1392,6 +1398,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||||
| 1392 | if sess != nil { | 1398 | if sess != nil { | ||
| 1393 | persistCompaction = sess.AppendCompaction | 1399 | persistCompaction = sess.AppendCompaction | ||
| 1394 | } | 1400 | } | ||
| 1401 | + | prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr) | |||
| 1395 | recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) | 1402 | recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) | ||
| 1396 | transcriptStart := start | 1403 | transcriptStart := start | ||
| 1397 | if recovery.Compacted { | 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 | var sessBaselineMsgs int // messages already on disk when current session opened | 1945 | var sessBaselineMsgs int // messages already on disk when current session opened | ||
| 1938 | var sessionTitlePending bool | 1946 | var sessionTitlePending bool | ||
| 1939 | // initialPlanSnapshots is reconstructed from the untrimmed resumed | 1947 | // initialPlanSnapshots is reconstructed from the untrimmed resumed | ||
| @@ -1942,7 +1950,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1942 | var initialPlanSnapshots map[string]core.PlanUpdate | 1950 | var initialPlanSnapshots map[string]core.PlanUpdate | ||
| 1943 | if !args.NoSess && ag != nil { | 1951 | if !args.NoSess && ag != nil { | ||
| 1944 | var sessErr error | 1952 | var sessErr error | ||
| 1945 | - | sess, sessErr = openOrCreateSession(ctx, args, r, ag, version) | 1953 | + | sess, freshSession, sessErr = openOrCreateSessionState(ctx, args, r, ag, version) |
| 1946 | if sessErr != nil { | 1954 | if sessErr != nil { | ||
| 1947 | return sessErr | 1955 | return sessErr | ||
| 1948 | } | 1956 | } | ||
| @@ -2386,6 +2394,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2386 | committed = true | 2394 | committed = true | ||
| 2387 | persistMu.Unlock() | 2395 | persistMu.Unlock() | ||
| 2388 | if iv != nil { | 2396 | if iv != nil { | ||
| 2397 | + | iv.SetPinnedSkillsPending(false) | |||
| 2389 | iv.RefreshGoal() | 2398 | iv.RefreshGoal() | ||
| 2390 | iv.RefreshPlan(candidate.planSnapshots) | 2399 | iv.RefreshPlan(candidate.planSnapshots) | ||
| 2391 | } | 2400 | } | ||
| @@ -2810,49 +2819,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2810 | setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow) | 2819 | setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow) | ||
| 2811 | return nil | 2820 | return nil | ||
| 2812 | }, | 2821 | }, | ||
| 2813 | - | AuthManager: mgr, | 2822 | + | AuthManager: mgr, |
| 2814 | - | LlamaCPPConfig: ResolveLlamaCPPConfig, | 2823 | + | LlamaCPPConfig: ResolveLlamaCPPConfig, |
| 2815 | - | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | 2824 | + | RefreshLlamaCPPModels: RefreshLlamaCPPModels, |
| 2816 | - | BuildAgent: buildAgent, | 2825 | + | RefreshCustomProviderModels: RefreshCustomProviderModels, |
| 2817 | - | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | 2826 | + | CustomDiscoveryConfigured: CustomDiscoveryConfigured, |
| 2818 | - | BuildAgentFor: buildAgentFor, | 2827 | + | BuildAgent: buildAgent, |
| 2819 | - | BuildAgentForRescue: buildAgentForRescue, | 2828 | + | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, |
| 2820 | - | LoggedInProviders: func() []string { | 2829 | + | BuildAgentFor: buildAgentFor, |
| 2821 | - | var out []string | 2830 | + | BuildAgentForRescue: buildAgentForRescue, |
| 2822 | - | seen := map[string]bool{} | 2831 | + | LoggedInProviders: modelPickerProviders, |
| 2823 | - | for _, p := range knownProviders { | 2832 | + | LoadSession: loadSession, |
| 2824 | - | if CredentialAvailable(p) && !seen[p] { | 2833 | + | LoadSessionContext: loadSessionContext, |
| 2825 | - | out = append(out, p) | 2834 | + | ChangeCWD: changeCWD, |
| 2826 | - | seen[p] = true | 2835 | + | PersistCompactHandoff: persistCompactHandoff, |
| 2827 | - | } | 2836 | + | CurrentCompactHandoff: currentCompactHandoff, |
| 2828 | - | } | 2837 | + | PersistGoal: persistGoal, |
| 2829 | - | // Include custom providers that have credentials stored. | 2838 | + | PersistGoalRuntime: persistGoalRuntime, |
| 2830 | - | for p := range provider.CustomProviders() { | 2839 | + | CurrentGoal: currentGoal, |
| 2831 | - | if CredentialAvailable(p) && !seen[p] { | 2840 | + | CurrentGoalHistory: currentGoalHistory, |
| 2832 | - | out = append(out, p) | 2841 | + | CurrentPlan: currentPlan, |
| 2833 | - | seen[p] = true | 2842 | + | PersistPlan: persistPlan, |
| 2834 | - | } | 2843 | + | PersistedPlan: persistedPlan, |
| 2835 | - | } | 2844 | + | InitialPlanSnapshots: initialPlanSnapshots, |
| 2836 | - | // Ollama models are always available (no auth needed). | 2845 | + | EnsureMission: ensureMission, |
| 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, | |||
| 2856 | CurrentSessionPath: func() string { | 2846 | CurrentSessionPath: func() string { | ||
| 2857 | persistMu.Lock() | 2847 | persistMu.Lock() | ||
| 2858 | defer persistMu.Unlock() | 2848 | defer persistMu.Unlock() | ||
| @@ -2931,23 +2921,18 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2931 | _ = MarkChangelogShown(v) | 2921 | _ = MarkChangelogShown(v) | ||
| 2932 | }, | 2922 | }, | ||
| 2933 | SkillSnapshot: func() []*skills.Skill { | 2923 | SkillSnapshot: func() []*skills.Skill { | ||
| 2924 | + | return userSkillSnapshot(args, r.CWD, extMgr.Skills()) | |||
| 2925 | + | }, | |||
| 2926 | + | LoadSkillPins: func(cwd string) (skills.Pins, error) { | |||
| 2934 | if args.NoSkill { | 2927 | if args.NoSkill { | ||
| 2935 | - | // --no-skill: nothing for the picker to show. | 2928 | + | return skills.Pins{}, nil |
| 2936 | - | return nil | |||
| 2937 | } | 2929 | } | ||
| 2938 | - | // Re-discover so the picker reflects edits made during | 2930 | + | return loadSkillPins(cwd) |
| 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) | |||
| 2948 | }, | 2931 | }, | ||
| 2949 | - | NoYolo: args.NoYolo, | 2932 | + | ToggleSkillPin: toggleSkillPin, |
| 2950 | - | ConfirmGate: confirmGate, | 2933 | + | PreloadPinnedSkills: freshSession && !args.NoSkill, |
| 2934 | + | NoYolo: args.NoYolo, | |||
| 2935 | + | ConfirmGate: confirmGate, | |||
| 2951 | PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) { | 2936 | PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) { | ||
| 2952 | if iv != nil { | 2937 | if iv != nil { | ||
| 2953 | iv.Notify("session", "error", message) | 2938 | iv.Notify("session", "error", message) | ||
| @@ -3157,90 +3142,8 @@ func agentSessionsRoot(root string, args Args) string { | |||||
| 3157 | // openOrCreateSession returns a session for the run. sess may be nil | 3142 | // openOrCreateSession returns a session for the run. sess may be nil | ||
| 3158 | // with a nil error if session persistence is disabled. | 3143 | // with a nil error if session persistence is disabled. | ||
| 3159 | func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | 3144 | func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | ||
| 3160 | - | if args.NoSess { | 3145 | + | sess, _, err := openOrCreateSessionState(ctx, args, r, ag, version) |
| 3161 | - | // Allocate the one logical identity for this non-persisted run during | 3146 | + | return sess, err |
| 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 | |||
| 3244 | } | 3147 | } | ||
| 3245 | 3148 | ||||
| 3246 | func pickSession(root, cwd string) (string, error) { | 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 | import ( | 34 | import ( | ||
| 35 | "bufio" | 35 | "bufio" | ||
| 36 | + | "context" | |||
| 36 | "encoding/base64" | 37 | "encoding/base64" | ||
| 37 | "encoding/json" | 38 | "encoding/json" | ||
| 38 | "fmt" | 39 | "fmt" | ||
| @@ -57,6 +58,13 @@ type CommandHandler func(args string) Response | |||||
| 57 | // ToolResult describing what zut should send back to the model. | 58 | // ToolResult describing what zut should send back to the model. | ||
| 58 | type ToolHandler func(args json.RawMessage) ToolResult | 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 | // EventHandler is called for each lifecycle event the extension | 68 | // EventHandler is called for each lifecycle event the extension | ||
| 61 | // subscribed to via Subscribe. The handler is invoked synchronously | 69 | // subscribed to via Subscribe. The handler is invoked synchronously | ||
| 62 | // on the read goroutine; keep it quick or hand off to your own | 70 | // on the read goroutine; keep it quick or hand off to your own | ||
| @@ -283,8 +291,9 @@ type Extension struct { | |||||
| 283 | mu sync.Mutex | 291 | mu sync.Mutex | ||
| 284 | commands map[string]CommandHandler | 292 | commands map[string]CommandHandler | ||
| 285 | descriptions []descTuple // ordered so register frames arrive in registration order | 293 | descriptions []descTuple // ordered so register frames arrive in registration order | ||
| 286 | - | tools map[string]ToolHandler | 294 | + | tools map[string]InteractiveToolHandler |
| 287 | - | toolDefs []toolDef // ordered so register frames arrive in registration order | 295 | + | toolCancels map[string]*toolCall // in-flight tool_call id -> cancel handle |
| 296 | + | toolDefs []toolDef // ordered so register frames arrive in registration order | |||
| 288 | eventHandlers map[string]EventHandler | 297 | eventHandlers map[string]EventHandler | ||
| 289 | eventNames []string // declared subscription order | 298 | eventNames []string // declared subscription order | ||
| 290 | 299 | ||||
| @@ -313,6 +322,12 @@ type toolDef struct { | |||||
| 313 | description string | 322 | description string | ||
| 314 | schema json.RawMessage | 323 | schema json.RawMessage | ||
| 315 | deferred bool | 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 | // HostInfo is what the host (zut) tells us in HelloAck. Useful for | 333 | // HostInfo is what the host (zut) tells us in HelloAck. Useful for | ||
| @@ -338,11 +353,12 @@ func New(name, version string) *Extension { | |||||
| 338 | out: os.Stdout, | 353 | out: os.Stdout, | ||
| 339 | stderr: os.Stderr, | 354 | stderr: os.Stderr, | ||
| 340 | commands: map[string]CommandHandler{}, | 355 | commands: map[string]CommandHandler{}, | ||
| 341 | - | tools: map[string]ToolHandler{}, | 356 | + | tools: map[string]InteractiveToolHandler{}, |
| 357 | + | toolCancels: map[string]*toolCall{}, | |||
| 342 | eventHandlers: map[string]EventHandler{}, | 358 | eventHandlers: map[string]EventHandler{}, | ||
| 343 | panelKeys: map[string]func(key, text string){}, | 359 | panelKeys: map[string]func(key, text string){}, | ||
| 344 | panelCloses: map[string]func(){}, | 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 | e.registerTool(name, description, schema, true, fn) | 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 | e.mu.Lock() | 464 | e.mu.Lock() | ||
| 442 | e.tools[name] = fn | 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 | e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred}) | 473 | e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred}) | ||
| 444 | e.mu.Unlock() | 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 | // On subscribes to a lifecycle event. fn is called for each | 489 | // On subscribes to a lifecycle event. fn is called for each | ||
| 448 | // notification; the same name can only have one handler (later | 490 | // notification; the same name can only have one handler (later | ||
| 449 | // registrations replace earlier ones). Recognised names include: | 491 | // registrations replace earlier ones). Recognised names include: | ||
| @@ -558,6 +600,7 @@ func (e *Extension) Alert(alert AlertRequest) { | |||||
| 558 | // Run starts the protocol loop. Blocks until stdin closes (zut has | 600 | // Run starts the protocol loop. Blocks until stdin closes (zut has | ||
| 559 | // shut us down). Returns the first fatal error, or nil on clean exit. | 601 | // shut us down). Returns the first fatal error, or nil on clean exit. | ||
| 560 | func (e *Extension) Run() error { | 602 | func (e *Extension) Run() error { | ||
| 603 | + | defer e.cancelAllTools() | |||
| 561 | scanner := bufio.NewScanner(e.in) | 604 | scanner := bufio.NewScanner(e.in) | ||
| 562 | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | 605 | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | ||
| 563 | 606 | ||||
| @@ -628,6 +671,7 @@ func (e *Extension) Run() error { | |||||
| 628 | Description: td.description, | 671 | Description: td.description, | ||
| 629 | Schema: td.schema, | 672 | Schema: td.schema, | ||
| 630 | Deferred: td.deferred, | 673 | Deferred: td.deferred, | ||
| 674 | + | Interactive: td.interactive, | |||
| 631 | }) | 675 | }) | ||
| 632 | } | 676 | } | ||
| 633 | var intercepts []string | 677 | var intercepts []string | ||
| @@ -698,15 +742,49 @@ func (e *Extension) Run() error { | |||||
| 698 | e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name))) | 742 | e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name))) | ||
| 699 | continue | 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 | defer func() { | 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 | e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r))) | 766 | e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r))) | ||
| 705 | } | 767 | } | ||
| 706 | }() | 768 | }() | ||
| 707 | - | res := fn(args) | 769 | + | res := fn(ctx, args) |
| 708 | - | e.respondTool(id, res) | 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 | }(tc.ID, fn, tc.Args) | 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 | case "event": | 788 | case "event": | ||
| 711 | var ef extproto.EventFromHost | 789 | var ef extproto.EventFromHost | ||
| 712 | if err := json.Unmarshal(line, &ef); err != nil { | 790 | if err := json.Unmarshal(line, &ef); err != nil { | ||
| @@ -759,6 +837,7 @@ func (e *Extension) Run() error { | |||||
| 759 | go h() | 837 | go h() | ||
| 760 | } | 838 | } | ||
| 761 | case "shutdown": | 839 | case "shutdown": | ||
| 840 | + | e.cancelAllTools() | |||
| 762 | _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"}) | 841 | _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"}) | ||
| 763 | return nil | 842 | return nil | ||
| 764 | default: | 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 | "os" | 22 | "os" | ||
| 23 | "os/exec" | 23 | "os/exec" | ||
| 24 | "path/filepath" | 24 | "path/filepath" | ||
| 25 | + | "slices" | |||
| 25 | "sort" | 26 | "sort" | ||
| 26 | "strings" | 27 | "strings" | ||
| 27 | "sync" | 28 | "sync" | ||
| 29 | + | "sync/atomic" | |||
| 28 | "syscall" | 30 | "syscall" | ||
| 29 | "time" | 31 | "time" | ||
| 30 | 32 | ||||
| @@ -108,11 +110,18 @@ type Extension struct { | |||||
| 108 | stdout io.ReadCloser | 110 | stdout io.ReadCloser | ||
| 109 | logFile *os.File | 111 | logFile *os.File | ||
| 110 | helloAck bool | 112 | helloAck bool | ||
| 111 | - | commands []extproto.RegisterCommandFromExt | 113 | + | // toolCancel records whether the extension advertised the "tool_cancel" |
| 112 | - | tools []extproto.RegisterToolFromExt | 114 | + | // capability in hello. Set during the handshake, before publication. |
| 113 | - | skills []*skills.Skill | 115 | + | toolCancel bool |
| 114 | - | 116 | + | commands []extproto.RegisterCommandFromExt | |
| 115 | - | writeMu sync.Mutex // serializes concurrent host→extension frames | 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 | // lifecycleFrames decouples session-event delivery from the host's | 126 | // lifecycleFrames decouples session-event delivery from the host's | ||
| 118 | // synchronous control paths. The queue preserves lifecycle ordering while | 127 | // synchronous control paths. The queue preserves lifecycle ordering while | ||
| @@ -192,6 +201,24 @@ type HostHooks interface { | |||||
| 192 | ClearWidget(extName, id string) | 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 | // AlertHostHooks is an optional extension to HostHooks. Hosts that support | 222 | // AlertHostHooks is an optional extension to HostHooks. Hosts that support | ||
| 196 | // structured alerts implement it without breaking older embedders that only | 223 | // structured alerts implement it without breaking older embedders that only | ||
| 197 | // implement the original HostHooks surface. | 224 | // implement the original HostHooks surface. | ||
| @@ -724,6 +751,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||||
| 724 | } | 751 | } | ||
| 725 | // Trust the manifest's name; ignore mismatch from the hello. | 752 | // Trust the manifest's name; ignore mismatch from the hello. | ||
| 726 | ext.helloAck = true | 753 | ext.helloAck = true | ||
| 754 | + | ext.toolCancel = slices.Contains(hello.Capabilities, "tool_cancel") | |||
| 727 | 755 | ||||
| 728 | m.mu.RLock() | 756 | m.mu.RLock() | ||
| 729 | var activeSession *extproto.SessionContext | 757 | var activeSession *extproto.SessionContext | ||
| @@ -746,6 +774,11 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||||
| 746 | return fmt.Errorf("send hello_ack (stderr log: %s): %w", logPath, err) | 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 | cleanup = false | 782 | cleanup = false | ||
| 750 | 783 | ||||
| 751 | // Spin up the lifecycle writer and read loop now that the handshake is done. | 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 | // Returns when stdout closes. | 861 | // Returns when stdout closes. | ||
| 829 | func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | 862 | func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | ||
| 830 | defer func() { | 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 | // On close, drop every command + tool this extension owned so | 871 | // On close, drop every command + tool this extension owned so | ||
| 832 | // future invocations don't dangle. The subprocess is gone; we | 872 | // future invocations don't dangle. The subprocess is gone; we | ||
| 833 | // won't hear back about its commands or tool calls anymore. | 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 | const lifecycleWriteGrace = 250 * time.Millisecond | 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 | func (ext *Extension) writeFrame(frame []byte) (int, error) { | 1099 | func (ext *Extension) writeFrame(frame []byte) (int, error) { | ||
| 1057 | if ext == nil || ext.stdin == nil { | 1100 | if ext == nil || ext.stdin == nil { | ||
| 1058 | return 0, fmt.Errorf("extension stdin is closed") | 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 | ext.writeMu.Lock() | 1106 | ext.writeMu.Lock() | ||
| 1061 | defer ext.writeMu.Unlock() | 1107 | defer ext.writeMu.Unlock() | ||
| 1062 | return ext.stdin.Write(frame) | 1108 | return ext.stdin.Write(frame) | ||
| 1063 | } | 1109 | } | ||
| 1064 | 1110 | ||||
| 1065 | - | // tryWriteLifecycleFrame bounds only a lifecycle writer's wait for the shared | 1111 | + | // orderedStdin returns the bounded transport when the extension was spawned |
| 1066 | - | // writer lock. Synchronous host frames retain their existing write behavior; | 1112 | + | // by this manager. The bool is false for plain writers. |
| 1067 | - | // a stalled lifecycle snapshot is dropped rather than delaying them. | 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 | func (ext *Extension) tryWriteLifecycleFrame(frame []byte) error { | 1125 | func (ext *Extension) tryWriteLifecycleFrame(frame []byte) error { | ||
| 1069 | if ext == nil || ext.stdin == nil { | 1126 | if ext == nil || ext.stdin == nil { | ||
| 1070 | return fmt.Errorf("extension stdin is closed") | 1127 | return fmt.Errorf("extension stdin is closed") | ||
| @@ -1283,6 +1340,8 @@ type ToolInfo struct { | |||||
| 1283 | Description string | 1340 | Description string | ||
| 1284 | Schema json.RawMessage | 1341 | Schema json.RawMessage | ||
| 1285 | Deferred bool | 1342 | Deferred bool | ||
| 1343 | + | // Interactive tools wait for user input and carry no reply deadline. | |||
| 1344 | + | Interactive bool | |||
| 1286 | } | 1345 | } | ||
| 1287 | 1346 | ||||
| 1288 | // Tools returns a snapshot of every (extension, tool) pair currently | 1347 | // Tools returns a snapshot of every (extension, tool) pair currently | ||
| @@ -1300,6 +1359,7 @@ func (m *Manager) Tools() []ToolInfo { | |||||
| 1300 | Description: t.Description, | 1359 | Description: t.Description, | ||
| 1301 | Schema: t.Schema, | 1360 | Schema: t.Schema, | ||
| 1302 | Deferred: t.Deferred, | 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 | // InvokeTool sends a tool_call to the owning extension and waits for | 1395 | // InvokeTool sends a tool_call to the owning extension and waits for | ||
| 1336 | // the matching tool_result. Used by the core.Tool wrapper that the | 1396 | // the matching tool_result. Used by the core.Tool wrapper that the | ||
| 1337 | // agent registers per extension-defined tool. | 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 | func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) { | 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 | m.mu.RLock() | 1410 | m.mu.RLock() | ||
| 1340 | ext, ok := m.toolIndex[name] | 1411 | ext, ok := m.toolIndex[name] | ||
| 1341 | m.mu.RUnlock() | 1412 | m.mu.RUnlock() | ||
| @@ -1348,6 +1419,11 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||||
| 1348 | ext.mu.Lock() | 1419 | ext.mu.Lock() | ||
| 1349 | ext.pendingTool[id] = ch | 1420 | ext.pendingTool[id] = ch | ||
| 1350 | ext.mu.Unlock() | 1421 | ext.mu.Unlock() | ||
| 1422 | + | defer func() { | |||
| 1423 | + | ext.mu.Lock() | |||
| 1424 | + | delete(ext.pendingTool, id) | |||
| 1425 | + | ext.mu.Unlock() | |||
| 1426 | + | }() | |||
| 1351 | 1427 | ||||
| 1352 | frame, _ := extproto.Encode(extproto.ToolCallFromHost{ | 1428 | frame, _ := extproto.Encode(extproto.ToolCallFromHost{ | ||
| 1353 | Type: "tool_call", | 1429 | Type: "tool_call", | ||
| @@ -1355,26 +1431,57 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||||
| 1355 | Name: name, | 1431 | Name: name, | ||
| 1356 | Args: args, | 1432 | Args: args, | ||
| 1357 | }) | 1433 | }) | ||
| 1358 | - | if _, err := ext.writeFrame(frame); err != nil { | 1434 | + | var disconnected <-chan struct{} |
| 1359 | - | ext.mu.Lock() | 1435 | + | var err error |
| 1360 | - | delete(ext.pendingTool, id) | 1436 | + | pipe, ordered := ext.orderedStdin() |
| 1361 | - | ext.mu.Unlock() | 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 | return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err) | 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 | select { | 1467 | select { | ||
| 1366 | case resp := <-ch: | 1468 | case resp := <-ch: | ||
| 1367 | return resp, nil | 1469 | return resp, nil | ||
| 1368 | - | case <-time.After(timeout): | 1470 | + | case <-deadline: |
| 1369 | - | ext.mu.Lock() | 1471 | + | cancelRemote() |
| 1370 | - | delete(ext.pendingTool, id) | 1472 | + | return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s: %w", ext.Manifest.Name, name, context.DeadlineExceeded) |
| 1371 | - | ext.mu.Unlock() | |||
| 1372 | - | return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name) | |||
| 1373 | case <-ctx.Done(): | 1473 | case <-ctx.Done(): | ||
| 1374 | - | ext.mu.Lock() | 1474 | + | cancelRemote() |
| 1375 | - | delete(ext.pendingTool, id) | |||
| 1376 | - | ext.mu.Unlock() | |||
| 1377 | return extproto.ToolResultFromExt{}, ctx.Err() | 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 | func stopExtensions(exts []*Extension, gracePeriod time.Duration) { | 1591 | func stopExtensions(exts []*Extension, gracePeriod time.Duration) { | ||
| 1592 | + | deadline := time.Now().Add(gracePeriod) | |||
| 1485 | for _, ext := range exts { | 1593 | for _, ext := range exts { | ||
| 1486 | if ext.stdin == nil { | 1594 | if ext.stdin == nil { | ||
| 1487 | continue | 1595 | continue | ||
| 1488 | } | 1596 | } | ||
| 1489 | if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil { | 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 | _ = ext.stdin.Close() | 1613 | _ = ext.stdin.Close() | ||
| 1493 | } | 1614 | } | ||
| 1494 | 1615 | ||||
| 1495 | - | deadline := time.Now().Add(gracePeriod) | |||
| 1496 | for _, ext := range exts { | 1616 | for _, ext := range exts { | ||
| 1497 | if ext.cmd == nil { | 1617 | if ext.cmd == nil { | ||
| 1498 | if ext.logFile != nil { | 1618 | if ext.logFile != nil { | ||
| @@ -1535,9 +1655,11 @@ func (m *Manager) All() []*Extension { | |||||
| 1535 | return out | 1655 | return out | ||
| 1536 | } | 1656 | } | ||
| 1537 | 1657 | ||||
| 1538 | - | // newCorrelationID returns a short non-cryptographic id. We don't | 1658 | + | var correlationSequence atomic.Uint64 |
| 1539 | - | // need uniqueness across processes, just within the lifetime of one | 1659 | + | |
| 1540 | - | // extension's pending map. | 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 | func newCorrelationID() string { | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -35,20 +36,45 @@ type extensionTool struct { | |||||
| 35 | manager *Manager | 36 | manager *Manager | ||
| 36 | timeout time.Duration | 37 | timeout time.Duration | ||
| 37 | deferred bool | 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 | // NewTool returns a core.Tool that round-trips invocations through | 59 | // NewTool returns a core.Tool that round-trips invocations through | ||
| 41 | - | // mgr to the extension that registered (name, schema). The default | 60 | + | // mgr to the extension that registered (name, schema). Ordinary tools |
| 42 | - | // per-call timeout is 60 seconds; callers can override. | 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 | func NewTool(mgr *Manager, info ToolInfo) core.Tool { | 64 | func NewTool(mgr *Manager, info ToolInfo) core.Tool { | ||
| 65 | + | timeout := defaultToolTimeout | |||
| 66 | + | if info.Interactive { | |||
| 67 | + | timeout = 0 | |||
| 68 | + | } | |||
| 44 | return &extensionTool{ | 69 | return &extensionTool{ | ||
| 45 | name: info.Name, | 70 | name: info.Name, | ||
| 46 | description: info.Description, | 71 | description: info.Description, | ||
| 47 | schema: info.Schema, | 72 | schema: info.Schema, | ||
| 48 | extension: info.Extension, | 73 | extension: info.Extension, | ||
| 49 | manager: mgr, | 74 | manager: mgr, | ||
| 50 | - | timeout: 60 * time.Second, | 75 | + | timeout: timeout, |
| 51 | deferred: info.Deferred, | 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 | func (t *extensionTool) Schema() json.RawMessage { return t.schema } | 83 | func (t *extensionTool) Schema() json.RawMessage { return t.schema } | ||
| 58 | func (t *extensionTool) Extension() string { return t.extension } | 84 | func (t *extensionTool) Extension() string { return t.extension } | ||
| 59 | func (t *extensionTool) Deferred() bool { return t.deferred } | 85 | func (t *extensionTool) Deferred() bool { return t.deferred } | ||
| 86 | + | func (t *extensionTool) Interactive() bool { return t.interactive } | |||
| 60 | 87 | ||||
| 61 | // Execute is what the agent calls when the LLM invokes the tool. It | 88 | // Execute is what the agent calls when the LLM invokes the tool. It | ||
| 62 | // hands args to the owning extension, waits up to t.timeout for the | 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 | if len(args) == 0 { | 92 | if len(args) == 0 { | ||
| 66 | args = json.RawMessage(`{}`) | 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 | resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout) | 103 | resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout) | ||
| 69 | if err != nil { | 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 | return core.ToolResult{ | 111 | return core.ToolResult{ | ||
| 71 | IsError: true, | 112 | IsError: true, | ||
| 72 | Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}}, | 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 | Description string `json:"description,omitempty"` | 48 | Description string `json:"description,omitempty"` | ||
| 49 | Schema json.RawMessage `json:"schema"` | 49 | Schema json.RawMessage `json:"schema"` | ||
| 50 | Deferred bool `json:"deferred,omitempty"` | 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 | type ReadyFromExt struct { | 58 | type ReadyFromExt struct { | ||
| @@ -247,6 +252,14 @@ type CommandInvokedFromHost struct { | |||||
| 247 | Args string `json:"args,omitempty"` | 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 | type ToolCallFromHost struct { | 263 | type ToolCallFromHost struct { | ||
| 251 | Type string `json:"type"` | 264 | Type string `json:"type"` | ||
| 252 | ID string `json:"id"` | 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 | // baked-in entries, so an uncached id must survive startup repair. | 304 | // baked-in entries, so an uncached id must survive startup repair. | ||
| 305 | if provider.AcceptsUnlistedModels(cfg.Provider) { | 305 | if provider.AcceptsUnlistedModels(cfg.Provider) { | ||
| 306 | // The live refresh will validate the id when it is available. | 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 | } else if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(modelID) { | 310 | } else if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(modelID) { | ||
| 308 | // Provider is a router and the id is route-qualified; preserve it. | 311 | // Provider is a router and the id is route-qualified; preserve it. | ||
| 309 | } else if m, err := provider.FindModel("", modelID); err == nil { | 312 | } else if m, err := provider.FindModel("", modelID); err == nil { | ||
| @@ -344,6 +347,8 @@ func RefreshModelsAsync(explicitProvider, explicitAPIKey string, explicitBaseURL | |||||
| 344 | baseURL = explicitBaseURL[0] | 347 | baseURL = explicitBaseURL[0] | ||
| 345 | } | 348 | } | ||
| 346 | go refreshModels(explicitProvider, explicitAPIKey, baseURL, "") | 349 | go refreshModels(explicitProvider, explicitAPIKey, baseURL, "") | ||
| 350 | + | go refreshCopilotModelAvailability() | |||
| 351 | + | go refreshCustomProvidersBackground() | |||
| 347 | go func() { | 352 | go func() { | ||
| 348 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | 353 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | ||
| 349 | defer cancel() | 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 | func refreshModelsAsyncForProvider(ctx context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) { | 379 | func refreshModelsAsyncForProvider(ctx context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) { | ||
| 355 | if ctx == nil { | 380 | if ctx == nil { | ||
| 356 | ctx = context.Background() | 381 | ctx = context.Background() | ||
| @@ -381,7 +406,7 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e | |||||
| 381 | if err != nil { | 406 | if err != nil { | ||
| 382 | return err | 407 | return err | ||
| 383 | } | 408 | } | ||
| 384 | - | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | 409 | + | provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL)) |
| 385 | return nil | 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 | if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil { | 191 | if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil { | ||
| 192 | t.Fatal(err) | 192 | t.Fatal(err) | ||
| 193 | } | 193 | } | ||
| 194 | - | provider.SetManagedModels(nil) | 194 | + | preserveProviderCatalog(t) |
| 195 | - | t.Cleanup(func() { provider.SetManagedModels(nil) }) | 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 | if err := RefreshLlamaCPPModels(context.Background()); err != nil { | 199 | if err := RefreshLlamaCPPModels(context.Background()); err != nil { | ||
| 198 | t.Fatal(err) | 200 | t.Fatal(err) | ||
| @@ -207,6 +209,9 @@ func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) { | |||||
| 207 | if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil { | 209 | if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil { | ||
| 208 | t.Fatal("unloaded model must not be selectable") | 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 | // TestValidateAndRepairConfig_MismatchedPair simulates the bug from a | 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 | if message.Text == "" && len(message.Images) == 0 { | 34 | if message.Text == "" && len(message.Images) == 0 { | ||
| 35 | return false | 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 | attachments := make([]clipboardImageAttachment, 0, len(message.Images)) | 41 | attachments := make([]clipboardImageAttachment, 0, len(message.Images)) | ||
| 39 | for index, image := range message.Images { | 42 | for index, image := range message.Images { | ||
| 40 | markerIndex := index + 1 | 43 | markerIndex := index + 1 | ||
M packages/agent/modes/double_escape.go +1 -0
| @@ -90,6 +90,7 @@ func (i *Interactive) sessionTreeEscapeBlocked() bool { | |||||
| 90 | i.settingsDialog != nil && i.settingsDialog.Active() || | 90 | i.settingsDialog != nil && i.settingsDialog.Active() || | ||
| 91 | i.sessionOpsDialog != nil && i.sessionOpsDialog.Active() || | 91 | i.sessionOpsDialog != nil && i.sessionOpsDialog.Active() || | ||
| 92 | i.sessionTreeDialog != nil && i.sessionTreeDialog.Active() || | 92 | i.sessionTreeDialog != nil && i.sessionTreeDialog.Active() || | ||
| 93 | + | i.timeline != nil && i.timeline.Active() || | |||
| 93 | i.extPanel != nil && i.extPanel.Active() || | 94 | i.extPanel != nil && i.extPanel.Active() || | ||
| 94 | i.confirmDialog != nil && i.confirmDialog.Active() { | 95 | i.confirmDialog != nil && i.confirmDialog.Active() { | ||
| 95 | return true | 96 | return true | ||
M packages/agent/modes/interactive.go +30 -0
| @@ -253,6 +253,12 @@ type InteractiveConfig struct { | |||||
| 253 | // the catalog before /model opens. | 253 | // the catalog before /model opens. | ||
| 254 | RefreshLlamaCPPModels func(context.Context) error | 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 | // BuildAgent is called after a successful login to (re)construct the | 262 | // BuildAgent is called after a successful login to (re)construct the | ||
| 257 | // agent with the fresh credential. It returns the new agent and | 263 | // agent with the fresh credential. It returns the new agent and | ||
| 258 | // the concrete provider/model in use. | 264 | // the concrete provider/model in use. | ||
| @@ -439,6 +445,20 @@ type InteractiveConfig struct { | |||||
| 439 | // so the picker reflects edits made during the session. | 445 | // so the picker reflects edits made during the session. | ||
| 440 | SkillSnapshot func() []*skills.Skill | 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 | // ChangelogChan, if non-nil, delivers release-notes for the | 462 | // ChangelogChan, if non-nil, delivers release-notes for the | ||
| 443 | // current binary version once at startup. Interactive opens a | 463 | // current binary version once at startup. Interactive opens a | ||
| 444 | // dismissible overlay when the channel produces a non-empty | 464 | // dismissible overlay when the channel produces a non-empty | ||
| @@ -850,9 +870,14 @@ type Interactive struct { | |||||
| 850 | telegramBridge *telegram.Bridge | 870 | telegramBridge *telegram.Bridge | ||
| 851 | sessionOpsDialog *sessionOpsDialog | 871 | sessionOpsDialog *sessionOpsDialog | ||
| 852 | sessionTreeDialog *sessionTreeDialog | 872 | sessionTreeDialog *sessionTreeDialog | ||
| 873 | + | timeline *timelineView | |||
| 853 | extPanel *extPanelDialog | 874 | extPanel *extPanelDialog | ||
| 854 | llamaConfigured bool | 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 | // completionTracker collects resident child terminal turns for delivery to | 881 | // completionTracker collects resident child terminal turns for delivery to | ||
| 857 | // the parent orchestration wave. | 882 | // the parent orchestration wave. | ||
| 858 | // turnCoordinator seals worker waves at manager-turn boundaries and decides | 883 | // turnCoordinator seals worker waves at manager-turn boundaries and decides | ||
| @@ -1098,6 +1123,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 1098 | settingsDialog: newSettingsDialog(), | 1123 | settingsDialog: newSettingsDialog(), | ||
| 1099 | sessionOpsDialog: newSessionOpsDialog(), | 1124 | sessionOpsDialog: newSessionOpsDialog(), | ||
| 1100 | sessionTreeDialog: newSessionTreeDialog(), | 1125 | sessionTreeDialog: newSessionTreeDialog(), | ||
| 1126 | + | timeline: newTimelineView(), | |||
| 1101 | extPanel: newExtPanelDialog(), | 1127 | extPanel: newExtPanelDialog(), | ||
| 1102 | extStatuses: map[string]map[string]extensionStatus{}, | 1128 | extStatuses: map[string]map[string]extensionStatus{}, | ||
| 1103 | extWidgets: map[string]map[string]extensionWidget{}, | 1129 | extWidgets: map[string]map[string]extensionWidget{}, | ||
| @@ -1110,6 +1136,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 1110 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | 1136 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | ||
| 1111 | compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff), | 1137 | compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff), | ||
| 1112 | } | 1138 | } | ||
| 1139 | + | i.skillPins = newSkillPinState(cfg.LoadSkillPins, cfg.ToggleSkillPin, cfg.SkillSnapshot) | |||
| 1113 | i.btwDialog.setCloseHook(func() { | 1140 | i.btwDialog.setCloseHook(func() { | ||
| 1114 | i.confirmDialog.CancelChildConfirmations("side chat closed") | 1141 | i.confirmDialog.CancelChildConfirmations("side chat closed") | ||
| 1115 | }) | 1142 | }) | ||
| @@ -1161,6 +1188,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 1161 | } | 1188 | } | ||
| 1162 | i.applyAutoSubagentsTool() | 1189 | i.applyAutoSubagentsTool() | ||
| 1163 | i.recoverGoalRun() | 1190 | i.recoverGoalRun() | ||
| 1191 | + | if cfg.PreloadPinnedSkills { | |||
| 1192 | + | i.SetPinnedSkillsPending(true) | |||
| 1193 | + | } | |||
| 1164 | return i | 1194 | return i | ||
| 1165 | } | 1195 | } | ||
| 1166 | 1196 | ||||
M packages/agent/modes/interactive_commands.go +79 -25
| @@ -3,6 +3,7 @@ package modes | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| @@ -224,7 +225,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 224 | // lock. It is part of the clear decision so a divergence still clears the | 225 | // lock. It is part of the clear decision so a divergence still clears the | ||
| 225 | // file. | 226 | // file. | ||
| 226 | persistedPlan := i.cfg.PersistedPlan != nil && i.cfg.PersistedPlan() != nil | 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 | // Read i.agent under i.mu, the same field the locked Agent accessor | 231 | // Read i.agent under i.mu, the same field the locked Agent accessor | ||
| 229 | // returns; the lock-free read this replaced could race a session swap. | 232 | // returns; the lock-free read this replaced could race a session swap. | ||
| 230 | clearPlan := i.agent != nil && len(i.agent.CurrentPlan()) > 0 | 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 | i.sessionInfoBlocks = nil | 245 | i.sessionInfoBlocks = nil | ||
| 243 | i.parkedTurn = 0 | 246 | i.parkedTurn = 0 | ||
| 244 | i.parkedTotal = 0 | 247 | i.parkedTotal = 0 | ||
| 248 | + | i.lastCtxInput = 0 | |||
| 245 | i.scrollOffset = 0 | 249 | i.scrollOffset = 0 | ||
| 246 | i.extNotes = nil | 250 | i.extNotes = nil | ||
| 247 | i.reloadErrors = nil | 251 | i.reloadErrors = nil | ||
| @@ -251,7 +255,10 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 251 | i.planRevision++ | 255 | i.planRevision++ | ||
| 252 | handoff, persistHandoff := i.resetCompactContinuationLocked() | 256 | handoff, persistHandoff := i.resetCompactContinuationLocked() | ||
| 253 | i.view.InvalidateRenderCache() | 257 | i.view.InvalidateRenderCache() | ||
| 258 | + | // A cleared conversation gets its pinned skills again. | |||
| 259 | + | i.armSkillPinsLocked(pinFetch) | |||
| 254 | i.mu.Unlock() | 260 | i.mu.Unlock() | ||
| 261 | + | pinDone() | |||
| 255 | if persistHandoff { | 262 | if persistHandoff { | ||
| 256 | i.persistCompactHandoff(handoff) | 263 | i.persistCompactHandoff(handoff) | ||
| 257 | } | 264 | } | ||
| @@ -290,16 +297,18 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 290 | case "/model": | 297 | case "/model": | ||
| 291 | if len(parts) >= 2 { | 298 | if len(parts) >= 2 { | ||
| 292 | i.applyModelSelection("", parts[1]) | 299 | i.applyModelSelection("", parts[1]) | ||
| 293 | - | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | 300 | + | } else if i.modelRefreshNeeded() { |
| 294 | i.mu.Lock() | 301 | i.mu.Lock() | ||
| 295 | i.modelRefreshing = true | 302 | i.modelRefreshing = true | ||
| 296 | i.statusOK = "Refreshing models" | 303 | i.statusOK = "Refreshing models" | ||
| 297 | i.statusErr = "" | 304 | i.statusErr = "" | ||
| 298 | i.mu.Unlock() | 305 | i.mu.Unlock() | ||
| 299 | go func() { | 306 | go func() { | ||
| 300 | - | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | 307 | + | err := i.refreshModelCatalogs(ctx) |
| 301 | - | defer cancel() | 308 | + | select { |
| 302 | - | i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)} | 309 | + | case i.modelRefresh <- modelRefreshResult{err: err}: |
| 310 | + | case <-ctx.Done(): | |||
| 311 | + | } | |||
| 303 | }() | 312 | }() | ||
| 304 | } else { | 313 | } else { | ||
| 305 | i.openModelPickerAfterRefresh(nil) | 314 | i.openModelPickerAfterRefresh(nil) | ||
| @@ -513,6 +522,41 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 513 | } | 522 | } | ||
| 514 | return false | 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 | func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | 560 | func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | ||
| 517 | var loggedIn []string | 561 | var loggedIn []string | ||
| 518 | if i.cfg.LoggedInProviders != nil { | 562 | if i.cfg.LoggedInProviders != nil { | ||
| @@ -521,7 +565,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |||||
| 521 | i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning) | 565 | i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning) | ||
| 522 | i.mu.Lock() | 566 | i.mu.Lock() | ||
| 523 | if refreshErr != nil { | 567 | if refreshErr != nil { | ||
| 524 | - | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | 568 | + | i.statusErr = "model refresh: " + refreshErr.Error() |
| 525 | i.statusOK = "" | 569 | i.statusOK = "" | ||
| 526 | } else if i.statusOK == "Refreshing models" { | 570 | } else if i.statusOK == "Refreshing models" { | ||
| 527 | i.statusOK = "" | 571 | i.statusOK = "" | ||
| @@ -805,20 +849,14 @@ func (i *Interactive) startOAuthFlow(provider string) { | |||||
| 805 | i.dialog.ShowWaiting(loginURL) | 849 | i.dialog.ShowWaiting(loginURL) | ||
| 806 | return | 850 | return | ||
| 807 | } | 851 | } | ||
| 808 | - | // Always run the manual/copy-code flow in parallel with the local | 852 | + | // One browser transaction: the callback server and the pasted-code path |
| 809 | - | // callback server so headless environments (docker, SSH) can paste | 853 | + | // share the same PKCE verifier and state inside the auth manager. |
| 810 | - | // the authorization code directly without first pressing 'p'. | 854 | + | loginURL, err := i.cfg.AuthManager.StartOAuth(provider) |
| 811 | - | _, err := i.cfg.AuthManager.StartOAuth(provider) | |||
| 812 | if err != nil { | 855 | if err != nil { | ||
| 813 | i.dialog.ShowResult(false, err.Error()) | 856 | i.dialog.ShowResult(false, err.Error()) | ||
| 814 | return | 857 | return | ||
| 815 | } | 858 | } | ||
| 816 | - | manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider) | 859 | + | i.dialog.ShowWaiting(loginURL) |
| 817 | - | if mErr == nil { | |||
| 818 | - | i.dialog.ShowWaiting(manualURL) | |||
| 819 | - | } else { | |||
| 820 | - | i.dialog.ShowResult(false, mErr.Error()) | |||
| 821 | - | } | |||
| 822 | } | 860 | } | ||
| 823 | func (i *Interactive) startManualOAuthFlow(provider string) { | 861 | func (i *Interactive) startManualOAuthFlow(provider string) { | ||
| 824 | if i.cfg.AuthManager == nil { | 862 | if i.cfg.AuthManager == nil { | ||
| @@ -830,7 +868,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) { | |||||
| 830 | i.dialog.ShowResult(false, err.Error()) | 868 | i.dialog.ShowResult(false, err.Error()) | ||
| 831 | return | 869 | return | ||
| 832 | } | 870 | } | ||
| 833 | - | i.dialog.url = url | 871 | + | i.dialog.ShowPasteCode(url) |
| 834 | i.invalidate() | 872 | i.invalidate() | ||
| 835 | } | 873 | } | ||
| 836 | func (i *Interactive) submitManualOAuthCode(code string) { | 874 | func (i *Interactive) submitManualOAuthCode(code string) { | ||
| @@ -838,10 +876,14 @@ func (i *Interactive) submitManualOAuthCode(code string) { | |||||
| 838 | return | 876 | return | ||
| 839 | } | 877 | } | ||
| 840 | go func() { | 878 | go func() { | ||
| 841 | - | if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil { | 879 | + | err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code) |
| 842 | - | i.dialog.ShowResult(false, err.Error()) | 880 | + | if err == nil || errors.Is(err, auth.ErrOAuthCanceled) { |
| 843 | - | i.invalidate() | 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 | func (i *Interactive) cancelAndWaitForIdle() { | 889 | func (i *Interactive) cancelAndWaitForIdle() { | ||
| @@ -910,6 +952,8 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||||
| 910 | i.invalidate() | 952 | i.invalidate() | ||
| 911 | return | 953 | return | ||
| 912 | } | 954 | } | ||
| 955 | + | // A typed /skill:name command is a genuine user prompt. | |||
| 956 | + | prompt = i.skillPins.consume(prompt) | |||
| 913 | if i.busy { | 957 | if i.busy { | ||
| 914 | // Keep the mutex held while enqueueing so the turn-completion | 958 | // Keep the mutex held while enqueueing so the turn-completion | ||
| 915 | // goroutine cannot publish idle and inspect an empty queue between | 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 | i.startTurn(ctx, prompt) | 978 | i.startTurn(ctx, prompt) | ||
| 935 | } | 979 | } | ||
| 936 | func (i *Interactive) openSkillsDialog() { | 980 | func (i *Interactive) openSkillsDialog() { | ||
| 937 | - | var list []*skills.Skill | 981 | + | i.openSkillsDialogWithPins() |
| 938 | - | if i.cfg.SkillSnapshot != nil { | |||
| 939 | - | list = i.cfg.SkillSnapshot() | |||
| 940 | - | } | |||
| 941 | - | i.skillsDialog.Open(list) | |||
| 942 | i.invalidate() | 982 | i.invalidate() | ||
| 943 | } | 983 | } | ||
| 944 | func (i *Interactive) openJumpDialog(args []string) { | 984 | func (i *Interactive) openJumpDialog(args []string) { | ||
| @@ -1204,6 +1244,16 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||||
| 1204 | i.mu.Lock() | 1244 | i.mu.Lock() | ||
| 1205 | i.cfg.Model = m.ID | 1245 | i.cfg.Model = m.ID | ||
| 1206 | i.agent.Model = m.ID | 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 | i.statusOK = "model: " + m.ID | 1257 | i.statusOK = "model: " + m.ID | ||
| 1208 | i.statusErr = "" | 1258 | i.statusErr = "" | ||
| 1209 | i.mu.Unlock() | 1259 | i.mu.Unlock() | ||
| @@ -1288,6 +1338,10 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||||
| 1288 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | 1338 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | ||
| 1289 | switch ev.Kind { | 1339 | switch ev.Kind { | ||
| 1290 | case "started": | 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 | i.dialog.ShowWaiting(ev.URL) | 1345 | i.dialog.ShowWaiting(ev.URL) | ||
| 1292 | case "browser_open": | 1346 | case "browser_open": | ||
| 1293 | // no-op | 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 | i.statusOK = "compaction cancelled" | 325 | i.statusOK = "compaction cancelled" | ||
| 326 | } | 326 | } | ||
| 327 | i.discardQueuedMessagesLocked(false) | 327 | i.discardQueuedMessagesLocked(false) | ||
| 328 | + | i.skillPins.recall(i.pendingCompactPrompt) | |||
| 328 | i.clearPendingCompactTurnLocked() | 329 | i.clearPendingCompactTurnLocked() | ||
| 329 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 330 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 330 | case err != nil: | 331 | case err != nil: | ||
| 331 | i.statusErr = "compaction failed: " + err.Error() | 332 | i.statusErr = "compaction failed: " + err.Error() | ||
| 332 | i.statusOK = "" | 333 | i.statusOK = "" | ||
| 333 | i.discardQueuedMessagesLocked(true) | 334 | i.discardQueuedMessagesLocked(true) | ||
| 335 | + | i.skillPins.recall(i.pendingCompactPrompt) | |||
| 334 | i.clearPendingCompactTurnLocked() | 336 | i.clearPendingCompactTurnLocked() | ||
| 335 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 337 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 336 | default: | 338 | default: | ||
M packages/agent/modes/interactive_host.go +19 -0
| @@ -103,6 +103,8 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||||
| 103 | i.prepareReplacementAgentLocked(ag) | 103 | i.prepareReplacementAgentLocked(ag) | ||
| 104 | i.compactContinuation = decodeCompactHandoff(compactHandoff) | 104 | i.compactContinuation = decodeCompactHandoff(compactHandoff) | ||
| 105 | i.agent = ag | 105 | i.agent = ag | ||
| 106 | + | // Resumed, imported and forked conversations already have context. | |||
| 107 | + | i.skillPins.disarm() | |||
| 106 | i.cfg.Provider = providerName | 108 | i.cfg.Provider = providerName | ||
| 107 | i.cfg.Model = model | 109 | i.cfg.Model = model | ||
| 108 | i.cfg.detachMismatchedModelProfile() | 110 | i.cfg.detachMismatchedModelProfile() | ||
| @@ -169,6 +171,12 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 169 | profiles, _ := subagents.Discover(cwd, home) | 171 | profiles, _ := subagents.Discover(cwd, home) | ||
| 170 | subagentsAddendum := subagents.SystemPromptAddendum(profiles) | 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 | i.agentMu.Lock() | 180 | i.agentMu.Lock() | ||
| 173 | defer i.agentMu.Unlock() | 181 | defer i.agentMu.Unlock() | ||
| 174 | i.mu.Lock() | 182 | i.mu.Lock() | ||
| @@ -180,6 +188,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 180 | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked()) | 188 | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked()) | ||
| 181 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | 189 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | ||
| 182 | i.view.StartupContextPaths = nil | 190 | i.view.StartupContextPaths = nil | ||
| 191 | + | i.armSkillPinsLocked(pinFetch) | |||
| 183 | i.view.InvalidateRenderCache() | 192 | i.view.InvalidateRenderCache() | ||
| 184 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | 193 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | ||
| 185 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | 194 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | ||
| @@ -306,6 +315,11 @@ func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput | |||||
| 306 | return | 315 | return | ||
| 307 | } | 316 | } | ||
| 308 | i.maybeStartSessionTitle(i.runCtx, text) | 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 | i.mu.Lock() | 323 | i.mu.Lock() | ||
| 310 | if i.busy { | 324 | if i.busy { | ||
| 311 | // Keep the interactive mutex held through Agent.QueueMessage so turn teardown | 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 | func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) { | 380 | func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) { | ||
| 367 | pending := i.takeQueuedMessagesLocked() | 381 | pending := i.takeQueuedMessagesLocked() | ||
| 382 | + | for _, message := range pending { | |||
| 383 | + | if !message.HostEvent { | |||
| 384 | + | i.skillPins.recall(message.Text) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 368 | if !preserveHostEvents { | 387 | if !preserveHostEvents { | ||
| 369 | return | 388 | return | ||
| 370 | } | 389 | } | ||
M packages/agent/modes/interactive_input_routing.go +26 -6
| @@ -73,6 +73,7 @@ func (i *Interactive) confirmChildActiveLocked() bool { | |||||
| 73 | i.settingsDialog.Active() || | 73 | i.settingsDialog.Active() || | ||
| 74 | i.sessionOpsDialog.Active() || | 74 | i.sessionOpsDialog.Active() || | ||
| 75 | i.sessionTreeDialog.Active() || | 75 | i.sessionTreeDialog.Active() || | ||
| 76 | + | i.timeline.Active() || | |||
| 76 | i.extPanel.Active() | 77 | i.extPanel.Active() | ||
| 77 | } | 78 | } | ||
| 78 | func (i *Interactive) restoreConfirmFocus() { | 79 | func (i *Interactive) restoreConfirmFocus() { | ||
| @@ -135,6 +136,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 135 | return false | 136 | return false | ||
| 136 | } | 137 | } | ||
| 137 | act := i.dialog.HandleKey(k) | 138 | act := i.dialog.HandleKey(k) | ||
| 139 | + | if act.Close && i.cfg.AuthManager != nil { | |||
| 140 | + | i.cfg.AuthManager.CancelOAuth() | |||
| 141 | + | } | |||
| 138 | if act.StartAPIKey { | 142 | if act.StartAPIKey { | ||
| 139 | i.startAPIKeyFlow(act.Provider) | 143 | i.startAPIKeyFlow(act.Provider) | ||
| 140 | } | 144 | } | ||
| @@ -377,6 +381,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 377 | i.invalidate() | 381 | i.invalidate() | ||
| 378 | return false | 382 | return false | ||
| 379 | } | 383 | } | ||
| 384 | + | if i.timeline.Active() { | |||
| 385 | + | i.handleTimelineKey(k) | |||
| 386 | + | return false | |||
| 387 | + | } | |||
| 380 | if i.sessionOpsDialog.Active() { | 388 | if i.sessionOpsDialog.Active() { | ||
| 381 | if k.Kind == tui.KeyCtrlC { | 389 | if k.Kind == tui.KeyCtrlC { | ||
| 382 | i.sessionOpsDialog.Close() | 390 | i.sessionOpsDialog.Close() | ||
| @@ -464,6 +472,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 464 | i.invalidate() | 472 | i.invalidate() | ||
| 465 | return false | 473 | return false | ||
| 466 | } | 474 | } | ||
| 475 | + | if i.handleSkillPinKey(k) { | |||
| 476 | + | i.invalidate() | |||
| 477 | + | return false | |||
| 478 | + | } | |||
| 467 | i.skillsDialog.HandleKey(k) | 479 | i.skillsDialog.HandleKey(k) | ||
| 468 | i.invalidate() | 480 | i.invalidate() | ||
| 469 | return false | 481 | return false | ||
| @@ -558,11 +570,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 558 | i.ed.Clear() | 570 | i.ed.Clear() | ||
| 559 | i.clipboardImages = nil | 571 | i.clipboardImages = nil | ||
| 560 | i.suggest.Reset() | 572 | i.suggest.Reset() | ||
| 561 | - | if ag != nil { | |||
| 562 | - | ag.DrainQueuedMessages() | |||
| 563 | - | } | |||
| 564 | i.mu.Lock() | 573 | i.mu.Lock() | ||
| 565 | - | i.queued = nil | 574 | + | i.discardQueuedMessagesLocked(false) |
| 566 | i.statusOK = "input cleared" | 575 | i.statusOK = "input cleared" | ||
| 567 | i.statusErr = "" | 576 | i.statusErr = "" | ||
| 568 | i.mu.Unlock() | 577 | i.mu.Unlock() | ||
| @@ -922,6 +931,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 922 | go i.telegramBridge.OnUserTyped(text) | 931 | go i.telegramBridge.OnUserTyped(text) | ||
| 923 | } | 932 | } | ||
| 924 | i.maybeStartSessionTitle(ctx, text) | 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 | // If a turn is already in flight, queue this prompt inside the | 938 | // If a turn is already in flight, queue this prompt inside the | ||
| 926 | // agent loop so it is delivered at the next safe model-call | 939 | // agent loop so it is delivered at the next safe model-call | ||
| 927 | // boundary instead of waiting for the whole run to finish. | 940 | // boundary instead of waiting for the whole run to finish. | ||
| @@ -1034,16 +1047,23 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |||||
| 1034 | return true | 1047 | return true | ||
| 1035 | } | 1048 | } | ||
| 1036 | func (i *Interactive) inputHistory() []string { | 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 | return nil | 1055 | return nil | ||
| 1039 | } | 1056 | } | ||
| 1040 | - | msgs := i.agent.Messages() | 1057 | + | msgs := agent.Messages() |
| 1041 | hist := make([]string, 0, len(msgs)) | 1058 | hist := make([]string, 0, len(msgs)) | ||
| 1042 | for _, m := range msgs { | 1059 | for _, m := range msgs { | ||
| 1043 | if !isForkableUserMessage(m) { | 1060 | if !isForkableUserMessage(m) { | ||
| 1044 | continue | 1061 | continue | ||
| 1045 | } | 1062 | } | ||
| 1046 | text := userMessageText(m) | 1063 | text := userMessageText(m) | ||
| 1064 | + | if expanded != "" && text == expanded { | |||
| 1065 | + | text = original | |||
| 1066 | + | } | |||
| 1047 | if strings.TrimSpace(text) == "" { | 1067 | if strings.TrimSpace(text) == "" { | ||
| 1048 | continue | 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 | dialogID = "session-tree" | 530 | dialogID = "session-tree" | ||
| 531 | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | 531 | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | ||
| 532 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth) | 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 | dialogTitle := floatingOverlayTitle(dialogID) | 537 | dialogTitle := floatingOverlayTitle(dialogID) | ||
| 535 | dialogRemovedTopRows := 0 | 538 | dialogRemovedTopRows := 0 | ||
| @@ -742,10 +745,15 @@ func (i *Interactive) redraw() { | |||||
| 742 | // content. The status block and editor get their own dedicated | 745 | // content. The status block and editor get their own dedicated | ||
| 743 | // blanks so spacing stays consistent whether or not a dialog or | 746 | // blanks so spacing stays consistent whether or not a dialog or | ||
| 744 | // popup is showing. | 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 | composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) { | 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 | bottom = append(bottom, suggest...) | 754 | bottom = append(bottom, suggest...) | ||
| 748 | bottom = append(bottom, queue...) | 755 | bottom = append(bottom, queue...) | ||
| 756 | + | bottom = append(bottom, pinnedSkillLines...) | |||
| 749 | lineInput := inputStyle == tui.InputStyleLines | 757 | lineInput := inputStyle == tui.InputStyleLines | ||
| 750 | statusBelow := statusPosition == tui.StatusPositionBelowInput | 758 | statusBelow := statusPosition == tui.StatusPositionBelowInput | ||
| 751 | workingBelow := workingPosition == tui.WorkingPositionBelowInput | 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 | ctx, cancel := context.WithCancel(context.Background()) | 18 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 19 | defer cancel() | 19 | defer cancel() | ||
| 20 | ag := core.NewAgent(nil, "model", "", nil) | 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 | release := i.beginCompletionDeliveryHold() | 26 | release := i.beginCompletionDeliveryHold() | ||
| 23 | defer release() | 27 | defer release() | ||
| 24 | i.TrackResidentSubagent("first", "turn-1") | 28 | i.TrackResidentSubagent("first", "turn-1") | ||
| @@ -27,24 +31,15 @@ func TestResidentCompletionBatchDoesNotReportTerminalSiblingAsRunning(t *testing | |||||
| 27 | tracker := i.ensureCompletionTracker() | 31 | tracker := i.ensureCompletionTracker() | ||
| 28 | tracker.Report(subagents.Completion{AgentID: "first", TurnID: "turn-1", Status: "completed"}) | 32 | tracker.Report(subagents.Completion{AgentID: "first", TurnID: "turn-1", Status: "completed"}) | ||
| 29 | tracker.Report(subagents.Completion{AgentID: "second", TurnID: "turn-2", Status: "completed"}) | 33 | tracker.Report(subagents.Completion{AgentID: "second", TurnID: "turn-2", Status: "completed"}) | ||
| 30 | - | i.requestCompletionDelivery() | 34 | + | i.deliverCompletionUpdates() |
| 31 | - | 35 | + | ||
| 32 | - | deadline := time.NewTimer(2 * time.Second) | 36 | + | queued := ag.PendingQueuedMessages() |
| 33 | - | defer deadline.Stop() | 37 | + | if len(queued) != 2 { |
| 34 | - | for { | 38 | + | t.Fatalf("queued completions = %#v, want 2", queued) |
| 35 | - | queued := ag.PendingQueuedMessages() | 39 | + | } |
| 36 | - | if len(queued) == 2 { | 40 | + | for _, message := range queued { |
| 37 | - | for _, message := range queued { | 41 | + | if strings.Contains(message.Text, "Still running:") { |
| 38 | - | if strings.Contains(message.Text, "Still running:") { | 42 | + | t.Fatalf("terminal sibling reported as running: %q", message.Text) |
| 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): | |||
| 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 | func (i *Interactive) openSessionOpsDialog() { | 690 | func (i *Interactive) openSessionOpsDialog() { | ||
| 691 | items := []sessionOpsItem{ | 691 | items := []sessionOpsItem{ | ||
| 692 | + | {label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"}, | |||
| 692 | {label: "export", action: "export", hint: "write the current session to a .zutsession file"}, | 693 | {label: "export", action: "export", hint: "write the current session to a .zutsession file"}, | ||
| 693 | {label: "import", action: "import", hint: "load a .zutsession file into this directory"}, | 694 | {label: "import", action: "import", hint: "load a .zutsession file into this directory"}, | ||
| 694 | {label: "fork", action: "fork", hint: "branch from a past user message into a new session"}, | 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 | func (i *Interactive) doSessionOp(action, arg string) { | 701 | func (i *Interactive) doSessionOp(action, arg string) { | ||
| 701 | switch action { | 702 | switch action { | ||
| 703 | + | case "timeline": | |||
| 704 | + | i.openTimeline() | |||
| 702 | case "export": | 705 | case "export": | ||
| 703 | i.doSessionExport(arg) | 706 | i.doSessionExport(arg) | ||
| 704 | case "import": | 707 | case "import": | ||
| @@ -709,7 +712,7 @@ func (i *Interactive) doSessionOp(action, arg string) { | |||||
| 709 | i.doSessionTree() | 712 | i.doSessionTree() | ||
| 710 | default: | 713 | default: | ||
| 711 | i.mu.Lock() | 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 | i.mu.Unlock() | 716 | i.mu.Unlock() | ||
| 714 | i.invalidate() | 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 | i.mu.Unlock() | 17 | i.mu.Unlock() | ||
| 18 | if !awaitingStartupPre { | 18 | if !awaitingStartupPre { | ||
| 19 | i.maybeStartSessionTitle(parent, text) | 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 | i.startTurn(parent, text) | 24 | i.startTurn(parent, text) | ||
| 22 | } | 25 | } | ||
| @@ -48,6 +51,9 @@ func (i *Interactive) completeStartupPre() { | |||||
| 48 | // single-threaded. Input entered while resources were reloading wins over the | 51 | // single-threaded. Input entered while resources were reloading wins over the | ||
| 49 | // deferred prefill rather than being overwritten. | 52 | // deferred prefill rather than being overwritten. | ||
| 50 | func (i *Interactive) applyStartupPreResult(result startupPreResult) { | 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 | if result.deferred == "" { | 57 | if result.deferred == "" { | ||
| 52 | return | 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 | return models[i].ID < models[j].ID | 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 | d.mu.Lock() | 134 | d.mu.Lock() | ||
| 135 | if d.step != llamaClosed { | 135 | if d.step != llamaClosed { | ||
| 136 | d.models = models | 136 | d.models = models | ||
M packages/agent/modes/login_dialog.go +60 -9
| @@ -18,15 +18,16 @@ type loginStep int | |||||
| 18 | // dialog must default to closed so nothing shows up until Open() is | 18 | // dialog must default to closed so nothing shows up until Open() is | ||
| 19 | // explicitly called. | 19 | // explicitly called. | ||
| 20 | const ( | 20 | const ( | ||
| 21 | - | loginStepClosed loginStep = iota | 21 | + | loginStepClosed loginStep = iota |
| 22 | - | loginStepMethod // pick apikey vs subscription | 22 | + | loginStepMethod // pick apikey vs subscription |
| 23 | - | loginStepProvider // pick anthropic vs openai vs kimi | 23 | + | loginStepProvider // pick anthropic vs openai vs kimi |
| 24 | - | loginStepLlamaURL // enter and validate llama.cpp router URL | 24 | + | loginStepOAuthMethod // pick browser or copy-code for Anthropic |
| 25 | - | loginStepLlamaKey // enter optional llama.cpp API key | 25 | + | loginStepLlamaURL // enter and validate llama.cpp router URL |
| 26 | - | loginStepWaiting // browser open, waiting for callback | 26 | + | loginStepLlamaKey // enter optional llama.cpp API key |
| 27 | - | loginStepPasteCode // user pastes the auth code here | 27 | + | loginStepWaiting // browser open, waiting for callback |
| 28 | - | loginStepInfo // informational setup guidance | 28 | + | loginStepPasteCode // user pastes the auth code here |
| 29 | - | loginStepDone // success or error, waiting for key to dismiss | 29 | + | loginStepInfo // informational setup guidance |
| 30 | + | loginStepDone // success or error, waiting for key to dismiss | |||
| 30 | ) | 31 | ) | ||
| 31 | 32 | ||||
| 32 | const loginProviderPageSize = 8 | 33 | const loginProviderPageSize = 8 | ||
| @@ -79,6 +80,7 @@ func (d *loginDialog) Open(zutHome string) { | |||||
| 79 | d.url = "" | 80 | d.url = "" | ||
| 80 | d.cursor = 0 | 81 | d.cursor = 0 | ||
| 81 | d.providerQuery = "" | 82 | d.providerQuery = "" | ||
| 83 | + | d.codeEd = nil | |||
| 82 | d.llamaEd = nil | 84 | d.llamaEd = nil | ||
| 83 | d.llamaURL = "" | 85 | d.llamaURL = "" | ||
| 84 | d.infoTitle = "" | 86 | d.infoTitle = "" | ||
| @@ -179,6 +181,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 179 | lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | 181 | lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | ||
| 180 | } | 182 | } | ||
| 181 | lines = append(lines, frameRule(th, width)) | 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 | case loginStepLlamaURL: | 196 | case loginStepLlamaURL: | ||
| 183 | lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width)) | 197 | lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width)) | ||
| 184 | lines = append(lines, th.FGColor(th.Muted, "llama.cpp router URL:")) | 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 | return d.handleMethodKey(k) | 452 | return d.handleMethodKey(k) | ||
| 439 | case loginStepProvider: | 453 | case loginStepProvider: | ||
| 440 | return d.handleProviderKey(k) | 454 | return d.handleProviderKey(k) | ||
| 455 | + | case loginStepOAuthMethod: | |||
| 456 | + | return d.handleOAuthMethodKey(k) | |||
| 441 | case loginStepLlamaURL, loginStepLlamaKey: | 457 | case loginStepLlamaURL, loginStepLlamaKey: | ||
| 442 | return d.handleLlamaKey(k) | 458 | return d.handleLlamaKey(k) | ||
| 443 | case loginStepWaiting: | 459 | case loginStepWaiting: | ||
| @@ -534,6 +550,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 534 | d.message = "" | 550 | d.message = "" | ||
| 535 | return loginDialogAction{} | 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 | d.step = loginStepWaiting | 558 | d.step = loginStepWaiting | ||
| 538 | if d.method == "apikey" { | 559 | if d.method == "apikey" { | ||
| 539 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | 560 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | ||
| @@ -543,6 +564,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 543 | return loginDialogAction{} | 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 | func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | 587 | func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | ||
| 547 | if k.Kind == tui.KeyEsc { | 588 | if k.Kind == tui.KeyEsc { | ||
| 548 | d.Close() | 589 | d.Close() | ||
| @@ -584,6 +625,16 @@ func (d *loginDialog) ShowWaiting(url string) { | |||||
| 584 | d.url = url | 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 | // ShowInfo transitions to an informational setup dialog. | 638 | // ShowInfo transitions to an informational setup dialog. | ||
| 588 | // No-op if the user has already dismissed the dialog. | 639 | // No-op if the user has already dismissed the dialog. | ||
| 589 | func (d *loginDialog) ShowInfo(title string, lines []string) { | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "net/url" | |||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | 10 | ||||
| 11 | + | "github.com/bnema/zut/packages/provider/auth" | |||
| 10 | "github.com/bnema/zut/packages/tui" | 12 | "github.com/bnema/zut/packages/tui" | ||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| @@ -168,3 +170,105 @@ func TestInteractiveAltCUsesTerminalClipboardBeforeSystemClipboard(t *testing.T) | |||||
| 168 | t.Fatalf("system clipboard helper ran: %v", err) | 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 | "github.com/bnema/zut/packages/tui" | 8 | "github.com/bnema/zut/packages/tui" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | // skillsDialog lists every discovered skill and lets the user view | 11 | + | // skillsDialog lists every discovered skill, shows its pin scopes, and lets |
| 12 | - | // the body of one inline. View is read-only — the model loads skills | 12 | + | // the user view the body of one inline. Viewing is read-only — the model loads |
| 13 | - | // itself via the `skill` tool. This dialog is for inspection. | 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 | type skillsDialog struct { | 15 | type skillsDialog struct { | ||
| 15 | active bool | 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 | skills []*skills.Skill | 19 | skills []*skills.Skill | ||
| 17 | cursor int | 20 | cursor int | ||
| 18 | viewing *skills.Skill // when non-nil, render the body instead of the list | 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 | out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)} | 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 | if len(d.skills) == 0 { | 108 | if len(d.skills) == 0 { | ||
| 103 | out = append(out, " "+th.FGColor(th.Muted, "no user skills loaded")) | 109 | out = append(out, " "+th.FGColor(th.Muted, "no user skills loaded")) | ||
| 104 | out = append(out, " "+th.FGColor(th.Muted, "add SKILL.md under $ZUT_HOME/skills, .zut/skills, .claude/skills, or .agents/skills")) | 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 | for i := start; i < end; i++ { | 120 | for i := start; i < end; i++ { | ||
| 115 | s := d.skills[i] | 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 | if i == d.cursor { | 124 | if i == d.cursor { | ||
| 118 | out = append(out, th.PadHighlight(" "+row, width)) | 125 | out = append(out, th.PadHighlight(" "+row, width)) | ||
| 119 | } else { | 126 | } else { | ||
| @@ -128,6 +135,32 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||||
| 128 | return out | 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 | func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | 164 | func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | ||
| 132 | s := d.viewing | 165 | s := d.viewing | ||
| 133 | out := []string{ | 166 | out := []string{ | ||
M packages/agent/modes/slash_suggest.go +33 -6
| @@ -59,7 +59,7 @@ var slashCatalog = []slashCommand{ | |||||
| 59 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | 59 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | ||
| 60 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | 60 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | ||
| 61 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | 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 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | 63 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | ||
| 64 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | 64 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | ||
| 65 | {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"}, | 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 | {Name: "/exit", Desc: "exit zut"}, | 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 | // slashSuggester renders the popup that appears when the editor starts | 86 | // slashSuggester renders the popup that appears when the editor starts | ||
| 79 | // with "/". It does not own any input state — the editor drives. | 87 | // with "/". It does not own any input state — the editor drives. | ||
| 80 | const slashSuggestPageSize = 8 | 88 | const slashSuggestPageSize = 8 | ||
| @@ -285,15 +293,26 @@ func newSlashSuggester() *slashSuggester { return &slashSuggester{} } | |||||
| 285 | // matches returns the commands whose name has input as a prefix. | 293 | // matches returns the commands whose name has input as a prefix. | ||
| 286 | // If input is just "/", everything is shown. | 294 | // If input is just "/", everything is shown. | ||
| 287 | func (s *slashSuggester) matches(input string) []slashCommand { | 295 | func (s *slashSuggester) matches(input string) []slashCommand { | ||
| 288 | - | input = strings.TrimRight(input, " ") | |||
| 289 | if input == "" || !strings.HasPrefix(input, "/") { | 296 | if input == "" || !strings.HasPrefix(input, "/") { | ||
| 290 | return nil | 297 | return nil | ||
| 291 | } | 298 | } | ||
| 292 | - | // If there is a space, the user has moved past the command name. | 299 | + | matchPrefix := strings.ToLower(input) |
| 293 | - | if idx := strings.IndexByte(input, ' '); idx >= 0 { | 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 | return nil | 313 | return nil | ||
| 295 | } | 314 | } | ||
| 296 | - | matchPrefix := strings.ToLower(input) | 315 | + | matchPrefix = strings.ToLower(input) |
| 297 | catalog := s.allCatalog() | 316 | catalog := s.allCatalog() | ||
| 298 | if strings.HasPrefix(matchPrefix, skillCommandPrefix) { | 317 | if strings.HasPrefix(matchPrefix, skillCommandPrefix) { | ||
| 299 | catalog = s.skills | 318 | catalog = s.skills | ||
| @@ -311,7 +330,15 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||||
| 311 | out = append(out, c) | 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 | // pruneOrphanHeaders removes header rows that have no commands | 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 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | 99 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | ||
| 68 | s := newSlashSuggester() | 100 | s := newSlashSuggester() | ||
| 69 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | 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 | "github.com/bnema/zut/packages/provider" | 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 | // runScheduledSession reconstructs an inactive session in the current zut | 26 | // runScheduledSession reconstructs an inactive session in the current zut | ||
| 16 | // process and appends the resulting turn to its existing transcript. The | 27 | // process and appends the resulting turn to its existing transcript. The | ||
| 17 | // caller serializes this with session transitions before invoking it. | 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 | if model == "" { | 55 | if model == "" { | ||
| 45 | model = base.Model | 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 | if err != nil { | 67 | if err != nil { | ||
| 49 | return fmt.Errorf("build scheduled session agent: %w", err) | 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 | // the related ecosystems works in zut unchanged. User skill roots are | 23 | // the related ecosystems works in zut unchanged. User skill roots are | ||
| 24 | // walked recursively, and nested files can also be addressed by their | 24 | // walked recursively, and nested files can also be addressed by their | ||
| 25 | // slash-separated path relative to the root (for example, | 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 | package skills | 28 | package skills | ||
| 28 | 29 | ||||
| 29 | import ( | 30 | import ( | ||
| 31 | + | "errors" | |||
| 30 | "fmt" | 32 | "fmt" | ||
| 31 | "io/fs" | 33 | "io/fs" | ||
| 32 | "os" | 34 | "os" | ||
| 33 | "path/filepath" | 35 | "path/filepath" | ||
| 36 | + | "runtime" | |||
| 34 | "sort" | 37 | "sort" | ||
| 35 | "strings" | 38 | "strings" | ||
| 39 | + | "syscall" | |||
| 36 | ) | 40 | ) | ||
| 37 | 41 | ||||
| 38 | // Skill is one discovered SKILL.md file. | 42 | // Skill is one discovered SKILL.md file. | ||
| @@ -237,11 +241,58 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error { | |||||
| 237 | } | 241 | } | ||
| 238 | return errs | 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 | for _, entry := range entries { | 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 | continue | 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 | return errs | 297 | return errs | ||
| 247 | } | 298 | } | ||
| @@ -250,49 +301,166 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error { | |||||
| 250 | // populates `seen` with first-match-wins per name or alias. Split out | 301 | // populates `seen` with first-match-wins per name or alias. Split out | ||
| 251 | // so Discover's includeUser=false path doesn't have to skip over a | 302 | // so Discover's includeUser=false path doesn't have to skip over a | ||
| 252 | // giant block. | 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 | func scanUserSkills(zutHome, cwd, userHome string, seen map[string]*Skill) []error { | 310 | func scanUserSkills(zutHome, cwd, userHome string, seen map[string]*Skill) []error { | ||
| 254 | var errs []error | 311 | var errs []error | ||
| 255 | for _, loc := range searchDirs(zutHome, cwd, userHome) { | 312 | for _, loc := range searchDirs(zutHome, cwd, userHome) { | ||
| 256 | - | _ = filepath.WalkDir(loc.dir, func(path string, entry fs.DirEntry, walkErr error) error { | 313 | + | errs = append(errs, scanUserSkillDir(loc, seen)...) |
| 257 | - | if walkErr != nil { | 314 | + | } |
| 258 | - | if !os.IsNotExist(walkErr) { | 315 | + | return errs |
| 259 | - | errs = append(errs, fmt.Errorf("%s: %w", path, walkErr)) | 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" { | 420 | + | if isDir { |
| 264 | - | return nil | 421 | + | walk(path, depth+1) |
| 422 | + | continue | |||
| 265 | } | 423 | } | ||
| 266 | - | 424 | + | if entry.Name() != "SKILL.md" { | |
| 267 | - | rel, err := filepath.Rel(loc.dir, filepath.Dir(path)) | 425 | + | continue |
| 268 | - | if err != nil { | |||
| 269 | - | errs = append(errs, fmt.Errorf("%s: determine skill name: %w", path, err)) | |||
| 270 | - | return nil | |||
| 271 | } | 426 | } | ||
| 272 | - | if rel == "." { | 427 | + | if dir == loc.dir { |
| 273 | // Keep the existing convention that a search root is a | 428 | // Keep the existing convention that a search root is a | ||
| 274 | // collection of skill directories, not a skill itself. | 429 | // collection of skill directories, not a skill itself. | ||
| 275 | - | return nil | 430 | + | continue |
| 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)) | |||
| 287 | } | 431 | } | ||
| 288 | - | addRelativeAlias(s, loc.dir, path) | 432 | + | loadSkill(path) |
| 289 | - | registerSkill(seen, s) | 433 | + | } |
| 290 | - | return nil | 434 | + | } |
| 291 | - | }) | 435 | + | walk(loc.dir, 0) |
| 436 | + | for i := 0; i < len(linkedDirs); i++ { | |||
| 437 | + | walk(linkedDirs[i].path, linkedDirs[i].depth) | |||
| 292 | } | 438 | } | ||
| 293 | return errs | 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 | // addRelativeAlias makes a nested skill addressable by the directory path | 464 | // addRelativeAlias makes a nested skill addressable by the directory path | ||
| 297 | // used by common skill routers, for example | 465 | // used by common skill routers, for example | ||
| 298 | // systems-backend/subskills/golang-patterns. | 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 | "strings" | 7 | "strings" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/zut/packages/agent/extensions" | |||
| 10 | "github.com/bnema/zut/packages/agent/subagents" | 11 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 11 | "github.com/bnema/zut/packages/agent/tools" | 12 | "github.com/bnema/zut/packages/agent/tools" | ||
| 12 | "github.com/bnema/zut/packages/core" | 13 | "github.com/bnema/zut/packages/core" | ||
| @@ -351,6 +352,9 @@ func residentChildRegistry(catalogue core.Registry, names []string) (core.Regist | |||||
| 351 | if !ok { | 352 | if !ok { | ||
| 352 | return nil, fmt.Errorf("resident child declares unavailable tool %q", name) | 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 | registry[name] = tool | 358 | registry[name] = tool | ||
| 355 | } | 359 | } | ||
| 356 | return registry, nil | 360 | return registry, nil | ||
M packages/agent/subagent_runtime.go +4 -0
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "sync" | 10 | "sync" | ||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | + | "github.com/bnema/zut/packages/agent/extensions" | |||
| 13 | "github.com/bnema/zut/packages/agent/subagents" | 14 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 14 | "github.com/bnema/zut/packages/agent/tools" | 15 | "github.com/bnema/zut/packages/agent/tools" | ||
| 15 | "github.com/bnema/zut/packages/core" | 16 | "github.com/bnema/zut/packages/core" | ||
| @@ -357,6 +358,9 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 357 | // scheduling/extension tools are never inherited. | 358 | // scheduling/extension tools are never inherited. | ||
| 358 | allTools := make([]string, 0, len(catalogue)) | 359 | allTools := make([]string, 0, len(catalogue)) | ||
| 359 | for name := range catalogue { | 360 | for name := range catalogue { | ||
| 361 | + | if extensions.IsInteractiveTool(catalogue[name]) { | |||
| 362 | + | continue | |||
| 363 | + | } | |||
| 360 | switch name { | 364 | switch name { | ||
| 361 | case tools.SubagentToolName, "update_goal", tools.PlanToolName: | 365 | case tools.SubagentToolName, "update_goal", tools.PlanToolName: | ||
| 362 | continue | 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 | } `json:"toolConfig,omitempty"` | 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 | func normalizeBedrockToolResults(msgs []Message) []Message { | 234 | func normalizeBedrockToolResults(msgs []Message) []Message { | ||
| 229 | - | resultByID := map[string]ToolResultBlock{} | 235 | + | msgs = RepairOrphanedToolResults(msgs) |
| 236 | + | resultsByID := map[string][]ToolResultBlock{} | |||
| 230 | for _, m := range msgs { | 237 | for _, m := range msgs { | ||
| 231 | for _, c := range m.Content { | 238 | for _, c := range m.Content { | ||
| 232 | if tr, ok := c.(ToolResultBlock); ok { | 239 | if tr, ok := c.(ToolResultBlock); ok { | ||
| 233 | - | if _, exists := resultByID[tr.CallID]; !exists { | 240 | + | resultsByID[tr.CallID] = append(resultsByID[tr.CallID], tr) |
| 234 | - | resultByID[tr.CallID] = tr | |||
| 235 | - | } | |||
| 236 | } | 241 | } | ||
| 237 | } | 242 | } | ||
| 238 | } | 243 | } | ||
| @@ -265,8 +270,9 @@ func normalizeBedrockToolResults(msgs []Message) []Message { | |||||
| 265 | 270 | ||||
| 266 | results := make([]Content, 0, len(toolCalls)) | 271 | results := make([]Content, 0, len(toolCalls)) | ||
| 267 | for _, tc := range toolCalls { | 272 | for _, tc := range toolCalls { | ||
| 268 | - | if tr, ok := resultByID[tc.ID]; ok { | 273 | + | if queue := resultsByID[tc.ID]; len(queue) > 0 { |
| 269 | - | results = append(results, tr) | 274 | + | results = append(results, queue[0]) |
| 275 | + | resultsByID[tc.ID] = queue[1:] | |||
| 270 | continue | 276 | continue | ||
| 271 | } | 277 | } | ||
| 272 | results = append(results, ToolResultBlock{ | 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 | func TestBedrockModelSupportsCaching(t *testing.T) { | 216 | func TestBedrockModelSupportsCaching(t *testing.T) { | ||
| 166 | cases := []struct { | 217 | cases := []struct { | ||
| 167 | model string | 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 | // Remove x-api-key entirely by NOT setting it. | 607 | // Remove x-api-key entirely by NOT setting it. | ||
| 608 | } else { | 608 | } else { | ||
| 609 | httpReq.Header.Set("accept", "text/event-stream") | 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 | // Extra headers (set by anthropic-messages-compatible third parties | 614 | // Extra headers (set by anthropic-messages-compatible third parties | ||
| 613 | // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.). | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "net" | 6 | "net" | ||
| 6 | "net/http" | 7 | "net/http" | ||
| 8 | + | "net/http/httptest" | |||
| 7 | "net/url" | 9 | "net/url" | ||
| 8 | "path/filepath" | 10 | "path/filepath" | ||
| 9 | "strings" | 11 | "strings" | ||
| @@ -293,3 +295,301 @@ func TestServerCallbackError(t *testing.T) { | |||||
| 293 | t.Fatal("no result") | 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 | manualEventProvider string | 40 | manualEventProvider string | ||
| 41 | manualPKCE PKCE | 41 | manualPKCE PKCE | ||
| 42 | manualState string | 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 | // NewManager returns a Manager bound to store. | 51 | // NewManager returns a Manager bound to store. | ||
| @@ -67,14 +73,8 @@ func (m *Manager) Close() { | |||||
| 67 | cancel() | 73 | cancel() | ||
| 68 | m.keyServer = nil | 74 | m.keyServer = nil | ||
| 69 | } | 75 | } | ||
| 70 | - | if m.oauthServer != nil { | 76 | + | // stopOAuthLocked also cancels an in-flight pasted-code exchange. |
| 71 | - | m.oauthServer.Shutdown() | 77 | + | m.stopOAuthLocked() |
| 72 | - | m.oauthServer = nil | |||
| 73 | - | } | |||
| 74 | - | if m.oauthCancel != nil { | |||
| 75 | - | m.oauthCancel() | |||
| 76 | - | m.oauthCancel = nil | |||
| 77 | - | } | |||
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | // ---- API key flow ---- | 80 | // ---- API key flow ---- | ||
| @@ -159,13 +159,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||||
| 159 | } | 159 | } | ||
| 160 | 160 | ||||
| 161 | m.mu.Lock() | 161 | m.mu.Lock() | ||
| 162 | - | if m.oauthServer != nil { | 162 | + | m.stopOAuthLocked() |
| 163 | - | m.oauthServer.Shutdown() | |||
| 164 | - | m.oauthServer = nil | |||
| 165 | - | } | |||
| 166 | - | if m.oauthCancel != nil { | |||
| 167 | - | m.oauthCancel() | |||
| 168 | - | } | |||
| 169 | m.mu.Unlock() | 163 | m.mu.Unlock() | ||
| 170 | 164 | ||||
| 171 | pkce, err := NewPKCE() | 165 | pkce, err := NewPKCE() | ||
| @@ -187,6 +181,14 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||||
| 187 | m.oauthServer = cs | 181 | m.oauthServer = cs | ||
| 188 | m.oauthCtx = ctx | 182 | m.oauthCtx = ctx | ||
| 189 | m.oauthCancel = cancel | 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 | m.mu.Unlock() | 192 | m.mu.Unlock() | ||
| 191 | 193 | ||||
| 192 | go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state) | 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 | res, err := cs.Result(waitCtx) | 205 | res, err := cs.Result(waitCtx) | ||
| 204 | if err != nil { | 206 | if err != nil { | ||
| 205 | if ctx.Err() == nil { | 207 | if ctx.Err() == nil { | ||
| 208 | + | m.mu.Lock() | |||
| 209 | + | if m.oauthCtx == ctx { | |||
| 210 | + | m.clearManualOAuthLocked() | |||
| 211 | + | } | |||
| 212 | + | m.mu.Unlock() | |||
| 206 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"}) | 213 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"}) | ||
| 207 | } | 214 | } | ||
| 208 | return | 215 | return | ||
| 209 | } | 216 | } | ||
| 217 | + | if ctx.Err() != nil { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 210 | if res.Err != nil { | 220 | if res.Err != nil { | ||
| 221 | + | m.mu.Lock() | |||
| 222 | + | if m.oauthCtx == ctx { | |||
| 223 | + | m.clearManualOAuthLocked() | |||
| 224 | + | } | |||
| 225 | + | m.mu.Unlock() | |||
| 211 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()}) | 226 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()}) | ||
| 212 | return | 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 | defer exCancel() | 241 | defer exCancel() | ||
| 217 | tok, err := op.Exchange(exCtx, res.Code, res.State, pkce) | 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 | if err != nil { | 251 | if err != nil { | ||
| 219 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | 252 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | ||
| 220 | return | 253 | return | ||
| @@ -230,9 +263,7 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvide | |||||
| 230 | func (m *Manager) StartKimiDeviceOAuth() (string, error) { | 263 | func (m *Manager) StartKimiDeviceOAuth() (string, error) { | ||
| 231 | ctx, cancel := context.WithCancel(context.Background()) | 264 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 232 | m.mu.Lock() | 265 | m.mu.Lock() | ||
| 233 | - | if m.oauthCancel != nil { | 266 | + | m.stopOAuthLocked() |
| 234 | - | m.oauthCancel() | |||
| 235 | - | } | |||
| 236 | m.oauthCtx = ctx | 267 | m.oauthCtx = ctx | ||
| 237 | m.oauthCancel = cancel | 268 | m.oauthCancel = cancel | ||
| 238 | m.mu.Unlock() | 269 | m.mu.Unlock() | ||
| @@ -265,9 +296,7 @@ func (m *Manager) StartKimiDeviceOAuth() (string, error) { | |||||
| 265 | func (m *Manager) StartXAIDeviceOAuth() (string, error) { | 296 | func (m *Manager) StartXAIDeviceOAuth() (string, error) { | ||
| 266 | ctx, cancel := context.WithCancel(context.Background()) | 297 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 267 | m.mu.Lock() | 298 | m.mu.Lock() | ||
| 268 | - | if m.oauthCancel != nil { | 299 | + | m.stopOAuthLocked() |
| 269 | - | m.oauthCancel() | |||
| 270 | - | } | |||
| 271 | m.oauthCtx = ctx | 300 | m.oauthCtx = ctx | ||
| 272 | m.oauthCancel = cancel | 301 | m.oauthCancel = cancel | ||
| 273 | m.mu.Unlock() | 302 | m.mu.Unlock() | ||
| @@ -300,9 +329,7 @@ func (m *Manager) StartXAIDeviceOAuth() (string, error) { | |||||
| 300 | func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) { | 329 | func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) { | ||
| 301 | ctx, cancel := context.WithCancel(context.Background()) | 330 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 302 | m.mu.Lock() | 331 | m.mu.Lock() | ||
| 303 | - | if m.oauthCancel != nil { | 332 | + | m.stopOAuthLocked() |
| 304 | - | m.oauthCancel() | |||
| 305 | - | } | |||
| 306 | m.oauthCtx = ctx | 333 | m.oauthCtx = ctx | ||
| 307 | m.oauthCancel = cancel | 334 | m.oauthCancel = cancel | ||
| 308 | m.mu.Unlock() | 335 | m.mu.Unlock() | ||
| @@ -354,8 +381,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 354 | case "anthropic": | 381 | case "anthropic": | ||
| 355 | op = AnthropicManualOAuth | 382 | op = AnthropicManualOAuth | ||
| 356 | case "openai", "openai-codex": | 383 | case "openai", "openai-codex": | ||
| 357 | - | op = OpenAIOAuth | 384 | + | return "", fmt.Errorf("openai login requires the browser callback flow; manual code login is not supported") |
| 358 | - | storeProvider = "openai" | |||
| 359 | case "google": | 385 | case "google": | ||
| 360 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | 386 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | ||
| 361 | case "deepseek": | 387 | case "deepseek": | ||
| @@ -364,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 364 | return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google") | 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 | pkce, err := NewPKCE() | 394 | pkce, err := NewPKCE() | ||
| 368 | if err != nil { | 395 | if err != nil { | ||
| 369 | return "", err | 396 | return "", err | ||
| @@ -389,27 +416,50 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 389 | // a token and stores it. Accepts either a raw code or a "code#state" | 416 | // a token and stores it. Accepts either a raw code or a "code#state" | ||
| 390 | // token shown by providers like Anthropic when code=true is set. | 417 | // token shown by providers like Anthropic when code=true is set. | ||
| 391 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | 418 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | ||
| 419 | + | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | |||
| 392 | m.mu.Lock() | 420 | m.mu.Lock() | ||
| 393 | - | op := m.manualOp | 421 | + | if m.manualOp == nil { |
| 394 | - | storeProvider := m.manualStoreProvider | 422 | + | m.mu.Unlock() |
| 395 | - | eventProvider := m.manualEventProvider | |||
| 396 | - | pkce := m.manualPKCE | |||
| 397 | - | state := m.manualState | |||
| 398 | - | m.mu.Unlock() | |||
| 399 | - | if op == nil { | |||
| 400 | return fmt.Errorf("no manual oauth flow in progress") | 423 | return fmt.Errorf("no manual oauth flow in progress") | ||
| 401 | } | 424 | } | ||
| 402 | - | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | 425 | + | if pastedState != "" && pastedState != m.manualState { |
| 403 | - | if pastedState != "" { | 426 | + | m.mu.Unlock() |
| 404 | - | state = pastedState | 427 | + | return fmt.Errorf("oauth state mismatch") |
| 405 | } | 428 | } | ||
| 406 | if code == "" { | 429 | if code == "" { | ||
| 430 | + | m.mu.Unlock() | |||
| 407 | return fmt.Errorf("empty code") | 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 | exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) | 443 | exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) | ||
| 444 | + | m.exchangeCtx, m.exchangeCancel = exCtx, cancel | |||
| 445 | + | m.mu.Unlock() | |||
| 410 | defer cancel() | 446 | defer cancel() | ||
| 447 | + | ||||
| 411 | tok, err := op.Exchange(exCtx, code, state, pkce) | 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 | if err != nil { | 459 | if err != nil { | ||
| 460 | + | if errors.Is(err, context.Canceled) { | |||
| 461 | + | return ErrOAuthCanceled | |||
| 462 | + | } | |||
| 413 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | 463 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | ||
| 414 | return err | 464 | return err | ||
| 415 | } | 465 | } | ||
| @@ -417,17 +467,14 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |||||
| 417 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | 467 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | ||
| 418 | return err | 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 | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) | 470 | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) | ||
| 428 | return nil | 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 | // parseManualCodeInput accepts any of: | 478 | // parseManualCodeInput accepts any of: | ||
| 432 | // - a bare authorization code | 479 | // - a bare authorization code | ||
| 433 | // - a "code#state" pair | 480 | // - a "code#state" pair | ||
| @@ -483,6 +530,17 @@ func HasBrowser() bool { | |||||
| 483 | func (m *Manager) CancelOAuth() { | 530 | func (m *Manager) CancelOAuth() { | ||
| 484 | m.mu.Lock() | 531 | m.mu.Lock() | ||
| 485 | defer m.mu.Unlock() | 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 | if m.oauthCancel != nil { | 544 | if m.oauthCancel != nil { | ||
| 487 | m.oauthCancel() | 545 | m.oauthCancel() | ||
| 488 | m.oauthCancel = nil | 546 | m.oauthCancel = nil | ||
| @@ -491,6 +549,15 @@ func (m *Manager) CancelOAuth() { | |||||
| 491 | m.oauthServer.Shutdown() | 549 | m.oauthServer.Shutdown() | ||
| 492 | m.oauthServer = nil | 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 | // ---- shared ---- | 563 | // ---- shared ---- | ||
M packages/provider/catalog_builtin.go +40 -13
| @@ -14,7 +14,14 @@ package provider | |||||
| 14 | // the small seed list for kimi / deepseek / google) are not duplicated | 14 | // the small seed list for kimi / deepseek / google) are not duplicated | ||
| 15 | // here; they take precedence on (provider, id) match. | 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 | var builtinCatalog = []Model{ | 26 | var builtinCatalog = []Model{ | ||
| 20 | // ----- amazon-bedrock ----- | 27 | // ----- amazon-bedrock ----- | ||
| @@ -141,28 +148,48 @@ var builtinCatalog = []Model{ | |||||
| 141 | {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"}, | 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 | {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"}, | 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 | // ----- github-copilot ----- | 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 | {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"}, | 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 | {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"}, | 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 | {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"}, | 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 | {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"}, | 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 | {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"}, | 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 | {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"}, | 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 | {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"}, | 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 | {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"}, | 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 | {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"}, | 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 | {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"}, | 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 | // ----- google ----- | 193 | // ----- google ----- | ||
| 167 | {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"}, | 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 | {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"}, | 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 | // User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`. | 14 | // User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`. | ||
| 15 | // 3. The short-lived token's value embeds a `proxy-ep=<host>` field that | 15 | // 3. The short-lived token's value embeds a `proxy-ep=<host>` field that | ||
| 16 | // tells us the real API host (individual users: api.individual.githubcopilot.com). | 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 | // short-lived token in `Authorization: Bearer` plus extras: | 18 | // short-lived token in `Authorization: Bearer` plus extras: | ||
| 19 | // - X-Initiator: user|agent | 19 | // - X-Initiator: user|agent | ||
| 20 | // - Openai-Intent: conversation-edits | 20 | // - Openai-Intent: conversation-edits | ||
| 21 | - | // - Copilot-Vision-Request: true (when images present; not wired | 21 | + | // - Copilot-Vision-Request: true (when images are present) |
| 22 | - | // here because the zut openai client currently sends images inline) | |||
| 23 | // | 22 | // | ||
| 24 | // Token caching: short-lived tokens last ~30min. We cache one per PAT in | 23 | // Token caching: short-lived tokens last ~30min. We cache one per PAT in | ||
| 25 | // memory for the process lifetime and refresh on demand. No disk cache. | 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 | clone := req.Clone(req.Context()) | 162 | clone := req.Clone(req.Context()) | ||
| 164 | clone.Header.Set("Authorization", "Bearer "+tok.value) | 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 | // Identity headers also required on inference requests. | 166 | // Identity headers also required on inference requests. | ||
| 166 | for k, v := range copilotIdentityHeaders { | 167 | for k, v := range copilotIdentityHeaders { | ||
| 167 | clone.Header.Set(k, v) | 168 | clone.Header.Set(k, v) | ||
| 168 | } | 169 | } | ||
| 169 | - | clone.Header.Set("X-Initiator", "agent") | 170 | + | if metadata, ok := req.Context().Value(copilotRequestKey{}).(copilotRequestMetadata); ok { |
| 170 | - | clone.Header.Set("Openai-Intent", "conversation-edits") | 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 | // If the request URL host doesn't match the token's proxy-ep, rewrite | 179 | // If the request URL host doesn't match the token's proxy-ep, rewrite | ||
| 172 | // it. The openaiClient pinned a static host at construction time, but | 180 | // it. The openaiClient pinned a static host at construction time, but | ||
| 173 | // the canonical host comes from the token. | 181 | // the canonical host comes from the token. | ||
| @@ -181,20 +189,61 @@ func (t *copilotRefreshTransport) RoundTrip(req *http.Request) (*http.Response, | |||||
| 181 | return t.inner.RoundTrip(clone) | 189 | return t.inner.RoundTrip(clone) | ||
| 182 | } | 190 | } | ||
| 183 | 191 | ||||
| 184 | - | // NewGithubCopilotClient returns a Copilot-pinned OpenAI-compat client. | 192 | + | // copilotResponsesStripTransport removes the codex client's ChatGPT |
| 185 | - | // The pat must be a GitHub Personal Access Token with Copilot access. | 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 | func NewGithubCopilotClient(pat string) Client { | 212 | func NewGithubCopilotClient(pat string) Client { | ||
| 187 | - | httpClient := &http.Client{ | 213 | + | refresh := &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat} |
| 188 | - | Transport: &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}, | 214 | + | completionsHTTP := &http.Client{Transport: refresh, Timeout: 0} |
| 189 | - | Timeout: 0, | |||
| 190 | - | } | |||
| 191 | // Initial baseURL is a sane default; copilotRefreshTransport rewrites | 215 | // Initial baseURL is a sane default; copilotRefreshTransport rewrites | ||
| 192 | // the host on every request based on the freshly-issued token. | 216 | // the host on every request based on the freshly-issued token. | ||
| 193 | - | return &openaiClient{ | 217 | + | completions := &openaiClient{ |
| 194 | apiKey: pat, // unused at the wire level (transport overrides Auth) but kept for parity | 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 | chatCompletionsPath: "/chat/completions", | 220 | chatCompletionsPath: "/chat/completions", | ||
| 197 | name: "github-copilot", | 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 | baseURL: "https://api.enterprise.copilot.proxy", | 31 | baseURL: "https://api.enterprise.copilot.proxy", | ||
| 32 | } | 32 | } | ||
| 33 | copilotCache.mu.Unlock() | 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 | mockRT := &mockRoundTripper{} | 40 | mockRT := &mockRoundTripper{} | ||
| 36 | transport := &copilotRefreshTransport{ | 41 | transport := &copilotRefreshTransport{ | ||
| @@ -68,3 +73,27 @@ func TestCopilotHostRewrite(t *testing.T) { | |||||
| 68 | t.Errorf("expected Host header (Request.Host) to be %q, got %q", wantHost, mockRT.lastReq.Host) | 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 | active []Model // live overlay merged in via SetLiveModels; nil = none yet | 491 | active []Model // live overlay merged in via SetLiveModels; nil = none yet | ||
| 492 | activeSet bool // true once SetLiveModels has run (even with empty live) | 492 | activeSet bool // true once SetLiveModels has run (even with empty live) | ||
| 493 | authoritativeProviderSet map[string]struct{} | 493 | authoritativeProviderSet map[string]struct{} | ||
| 494 | - | managedModels []Model // ephemeral models exposed by local model managers | 494 | + | managedModels []Model // ephemeral models exposed by local model managers |
| 495 | - | userModels []Model // highest-precedence models loaded from models.json | 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 | // CatalogSnapshot captures the mutable catalog overlays so a caller can | 499 | // CatalogSnapshot captures the mutable catalog overlays so a caller can | ||
| @@ -505,6 +506,7 @@ type CatalogSnapshot struct { | |||||
| 505 | authoritativeProviderSet map[string]struct{} | 506 | authoritativeProviderSet map[string]struct{} | ||
| 506 | managedModels []Model | 507 | managedModels []Model | ||
| 507 | userModels []Model | 508 | userModels []Model | ||
| 509 | + | modelAvailability map[string]map[string]bool | |||
| 508 | } | 510 | } | ||
| 509 | 511 | ||||
| 510 | // SnapshotCatalog returns a deep copy of the mutable provider catalog state. | 512 | // SnapshotCatalog returns a deep copy of the mutable provider catalog state. | ||
| @@ -515,6 +517,7 @@ func SnapshotCatalog() CatalogSnapshot { | |||||
| 515 | snapshot := CatalogSnapshot{ | 517 | snapshot := CatalogSnapshot{ | ||
| 516 | activeSet: activeSet, | 518 | activeSet: activeSet, | ||
| 517 | authoritativeProviderSet: maps.Clone(authoritativeProviderSet), | 519 | authoritativeProviderSet: maps.Clone(authoritativeProviderSet), | ||
| 520 | + | modelAvailability: cloneModelAvailability(modelAvailability), | |||
| 518 | } | 521 | } | ||
| 519 | if active != nil { | 522 | if active != nil { | ||
| 520 | snapshot.active = cloneModels(active) | 523 | snapshot.active = cloneModels(active) | ||
| @@ -540,6 +543,10 @@ func RestoreCatalog(snapshot CatalogSnapshot) { | |||||
| 540 | } | 543 | } | ||
| 541 | activeSet = snapshot.activeSet | 544 | activeSet = snapshot.activeSet | ||
| 542 | authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet) | 545 | authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet) | ||
| 546 | + | modelAvailability = cloneModelAvailability(snapshot.modelAvailability) | |||
| 547 | + | if modelAvailability == nil { | |||
| 548 | + | modelAvailability = map[string]map[string]bool{} | |||
| 549 | + | } | |||
| 543 | if snapshot.managedModels != nil { | 550 | if snapshot.managedModels != nil { | ||
| 544 | managedModels = cloneModels(snapshot.managedModels) | 551 | managedModels = cloneModels(snapshot.managedModels) | ||
| 545 | } else { | 552 | } else { | ||
| @@ -629,9 +636,18 @@ func mergeUserModels(active, users []Model) []Model { | |||||
| 629 | } | 636 | } | ||
| 630 | existing.Source = "user" | 637 | existing.Source = "user" | ||
| 631 | existing.Speculative = false | 638 | existing.Speculative = false | ||
| 639 | + | if existing.Provider == ProviderGitHubCopilot { | |||
| 640 | + | configureCopilotModel(&existing) // normalizes a legacy api value | |||
| 641 | + | } | |||
| 632 | active[idx] = existing | 642 | active[idx] = existing | ||
| 633 | continue | 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 | index[key] = len(active) | 651 | index[key] = len(active) | ||
| 636 | active = append(active, user) | 652 | active = append(active, user) | ||
| 637 | } | 653 | } | ||
| @@ -692,7 +708,11 @@ func ClearLiveModelsForProvider(name string) { | |||||
| 692 | // Snapshotting Catalog at var-init time would freeze the picker to the | 708 | // Snapshotting Catalog at var-init time would freeze the picker to the | ||
| 693 | // curated seed list and drop every extra provider (openrouter, groq, | 709 | // curated seed list and drop every extra provider (openrouter, groq, | ||
| 694 | // xai, ...). Deferring the read to call time avoids that ordering trap. | 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 | activeMu.RLock() | 716 | activeMu.RLock() | ||
| 697 | defer activeMu.RUnlock() | 717 | defer activeMu.RUnlock() | ||
| 698 | src := active | 718 | src := active | ||
| @@ -719,7 +739,56 @@ func Active() []Model { | |||||
| 719 | // User models are a durable, highest-precedence overlay. Apply them | 739 | // User models are a durable, highest-precedence overlay. Apply them | ||
| 720 | // after the live and managed overlays so a catalog refresh cannot | 740 | // after the live and managed overlays so a catalog refresh cannot | ||
| 721 | // discard overrides or user-only entries. | 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 | // SetManagedModels replaces the ephemeral catalog entries supplied by local | 794 | // SetManagedModels replaces the ephemeral catalog entries supplied by local | ||
| @@ -731,11 +800,32 @@ func SetManagedModels(models []Model) { | |||||
| 731 | managedModels = append([]Model(nil), models...) | 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 | // FindModel returns a Model by id, optionally constrained by provider. | 823 | // FindModel returns a Model by id, optionally constrained by provider. | ||
| 735 | // If provider is empty, the first matching id is returned. Looks up | 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 | func FindModel(provider, id string) (Model, error) { | 827 | func FindModel(provider, id string) (Model, error) { | ||
| 738 | - | for _, m := range Active() { | 828 | + | for _, m := range activeModels(false) { |
| 739 | if m.ID == id && (provider == "" || m.Provider == provider) { | 829 | if m.ID == id && (provider == "" || m.Provider == provider) { | ||
| 740 | return m, nil | 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 | httpReq.Header.Set("content-type", "application/json") | 615 | httpReq.Header.Set("content-type", "application/json") | ||
| 616 | httpReq.Header.Set("accept", "text/event-stream") | 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 | for k, v := range c.headers { | 621 | for k, v := range c.headers { | ||
| 619 | httpReq.Header.Set(k, v) | 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 | if clone.Header.Get("x-opencode-client") != openCodeGoClient { | 53 | if clone.Header.Get("x-opencode-client") != openCodeGoClient { | ||
| 54 | clone.Header.Del("user-agent") | 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 | return t.inner.RoundTrip(clone) | 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 | func TestGithubCopilotChatCompletionsURL(t *testing.T) { | 42 | func TestGithubCopilotChatCompletionsURL(t *testing.T) { | ||
| 43 | - | client, ok := NewGithubCopilotClient("test-token").(*openaiClient) | 43 | + | copilot, ok := NewGithubCopilotClient("test-token").(*copilotClient) |
| 44 | if !ok { | 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 | const want = "https://api.individual.githubcopilot.com/chat/completions" | 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 | func (ReasoningBlock) isContent() {} | 132 | func (ReasoningBlock) isContent() {} | ||
| 133 | 133 | ||||
| 134 | // RepairOrphanedToolResults removes invalid tool_result content blocks | 134 | // RepairOrphanedToolResults removes invalid tool_result content blocks | ||
| 135 | - | // and entire messages that become empty. A result is invalid when its | 135 | + | // and entire messages that become empty. A result is invalid when there |
| 136 | - | // matching tool_use ID does not appear anywhere in the messages or when | 136 | + | // is no preceding unmatched tool_use with the same ID in the messages. |
| 137 | - | // an earlier result already covers the same ID. Resume tails, compaction | 137 | + | // Tool IDs normally are unique, but a provider can reuse one across turns. |
| 138 | - | // repair, and provider request builders all need this so the upstream API | 138 | + | // Counting occurrences preserves each valid pair while still dropping |
| 139 | - | // sees exactly one result for each referenced tool call. | 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 | func RepairOrphanedToolResults(msgs []Message) []Message { | 142 | func RepairOrphanedToolResults(msgs []Message) []Message { | ||
| 141 | - | useIDs := map[string]bool{} | 143 | + | openCalls := map[string]int{} |
| 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{} | |||
| 150 | out := make([]Message, 0, len(msgs)) | 144 | out := make([]Message, 0, len(msgs)) | ||
| 151 | for _, m := range msgs { | 145 | for _, m := range msgs { | ||
| 152 | var filtered []Content | 146 | var filtered []Content | ||
| 153 | for _, c := range m.Content { | 147 | for _, c := range m.Content { | ||
| 154 | - | if tr, ok := c.(ToolResultBlock); ok { | 148 | + | switch block := c.(type) { |
| 155 | - | if !useIDs[tr.CallID] || resultIDs[tr.CallID] { | 149 | + | case ToolCallBlock: |
| 150 | + | openCalls[block.ID]++ | |||
| 151 | + | case ToolResultBlock: | |||
| 152 | + | if openCalls[block.CallID] == 0 { | |||
| 156 | continue | 153 | continue | ||
| 157 | } | 154 | } | ||
| 158 | - | resultIDs[tr.CallID] = true | 155 | + | openCalls[block.CallID]-- |
| 159 | } | 156 | } | ||
| 160 | filtered = append(filtered, c) | 157 | filtered = append(filtered, c) | ||
| 161 | } | 158 | } | ||
M packages/provider/providers.go +9 -0
| @@ -54,6 +54,15 @@ var builtinProviderIDs = []string{ | |||||
| 54 | ProviderCloudflareWorkersAI, ProviderCloudflareAIGateway, | 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 | // BuiltinProviderIDs returns the canonical built-in provider IDs in fallback | 66 | // BuiltinProviderIDs returns the canonical built-in provider IDs in fallback | ||
| 58 | // priority order. The returned slice is independent of package state. | 67 | // priority order. The returned slice is independent of package state. | ||
| 59 | func BuiltinProviderIDs() []string { | 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 | // UserProvider groups models under a provider key. | 38 | // UserProvider groups models under a provider key. | ||
| 39 | type UserProvider struct { | 39 | type UserProvider struct { | ||
| 40 | - | BaseURL string `json:"baseUrl,omitempty"` | 40 | + | BaseURL string `json:"baseUrl,omitempty"` |
| 41 | - | API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic" | 41 | + | API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic" |
| 42 | - | Models []UserModel `json:"models"` | 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 | // CustomProviderConfig holds runtime config for a user-defined provider | 50 | // CustomProviderConfig holds runtime config for a user-defined provider | ||
| @@ -47,6 +52,10 @@ type UserProvider struct { | |||||
| 47 | type CustomProviderConfig struct { | 52 | type CustomProviderConfig struct { | ||
| 48 | BaseURL string | 53 | BaseURL string | ||
| 49 | API string // "openai", "openai-responses", or "anthropic" | 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 | var ( | 61 | var ( | ||
| @@ -167,7 +176,30 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||||
| 167 | break | 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 | if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL { | 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 | api := strings.ToLower(strings.TrimSpace(prov.API)) | 203 | api := strings.ToLower(strings.TrimSpace(prov.API)) | ||
| 172 | if api == "" { | 204 | if api == "" { | ||
| 173 | api = "openai" | 205 | api = "openai" | ||
| @@ -185,8 +217,10 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||||
| 185 | api = "openai" | 217 | api = "openai" | ||
| 186 | } | 218 | } | ||
| 187 | customProviders[normalized] = CustomProviderConfig{ | 219 | customProviders[normalized] = CustomProviderConfig{ | ||
| 188 | - | BaseURL: prov.BaseURL, | 220 | + | BaseURL: prov.BaseURL, |
| 189 | - | API: api, | 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 | modelBaseURL = prov.BaseURL | 251 | modelBaseURL = prov.BaseURL | ||
| 218 | } | 252 | } | ||
| 219 | modelAPI := "" | 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 | modelAPI = cfg.API | 257 | modelAPI = cfg.API | ||
| 222 | } | 258 | } | ||
| 223 | m := Model{ | 259 | m := Model{ | ||
M packages/provider/usermodels_test.go +140 -0
| @@ -254,3 +254,143 @@ func TestUserPriceOverrideClearsCatalogTiers(t *testing.T) { | |||||
| 254 | t.Fatalf("cost = %v, want user base price %v", got, want) | 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 | return extLang[ext] | 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 | // stripANSIBackgrounds removes SGR background-color attributes emitted by | 254 | // stripANSIBackgrounds removes SGR background-color attributes emitted by | ||
| 257 | // terminal syntax formatters while preserving foreground colors and styles. | 255 | // terminal syntax formatters while preserving foreground colors and styles. | ||
| 258 | // Chroma's inherited styles can assign black backgrounds to a few tokens | 256 | // Chroma's inherited styles can assign black backgrounds to a few tokens | ||
| 259 | // (notably punctuation/error-ish spans), which looks like random black blocks | 257 | // (notably punctuation/error-ish spans), which looks like random black blocks | ||
| 260 | // inside zut's dark-gray tool boxes. Foreground color is enough for code. | 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 | func stripANSIBackgrounds(s string) string { | 266 | func stripANSIBackgrounds(s string) string { | ||
| 262 | var out strings.Builder | 267 | var out strings.Builder | ||
| 263 | out.Grow(len(s)) | 268 | out.Grow(len(s)) | ||
| @@ -280,16 +285,14 @@ func stripANSIBackgrounds(s string) string { | |||||
| 280 | for p := 0; p < len(params); p++ { | 285 | for p := 0; p < len(params); p++ { | ||
| 281 | param := params[p] | 286 | param := params[p] | ||
| 282 | switch param { | 287 | switch param { | ||
| 283 | - | case "48": | 288 | + | case "38", "48": |
| 284 | - | // 48;5;N or 48;2;R;G;B background color. | 289 | + | span, complete := extendedColorSpan(params, p) |
| 285 | - | if p+1 < len(params) && params[p+1] == "5" { | 290 | + | if complete && param == "38" { |
| 286 | - | p += 2 | 291 | + | kept = append(kept, span...) |
| 287 | - | continue | |||
| 288 | - | } | |||
| 289 | - | if p+1 < len(params) && params[p+1] == "2" { | |||
| 290 | - | p += 4 | |||
| 291 | - | continue | |||
| 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 | continue | 296 | continue | ||
| 294 | case "49": | 297 | case "49": | ||
| 295 | continue | 298 | continue | ||
| @@ -313,6 +316,32 @@ func stripANSIBackgrounds(s string) string { | |||||
| 313 | return out.String() | 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 | func chooseLexer(lang string) chroma.Lexer { | 345 | func chooseLexer(lang string) chroma.Lexer { | ||
| 317 | if lang == "" { | 346 | if lang == "" { | ||
| 318 | return nil | 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 | return Key{}, err | 275 | return Key{}, err | ||
| 276 | } | 276 | } | ||
| 277 | switch c { | 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 | case 'H': | 287 | case 'H': | ||
| 279 | return Key{Kind: KeyHome}, nil | 288 | return Key{Kind: KeyHome}, nil | ||
| 280 | case 'F': | 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 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | 265 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | ||
| 247 | cases := []struct { | 266 | cases := []struct { | ||
| 248 | seq string | 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 | + | } | |||