detached ad3af57

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 UnverifiedB5690EEEBB952194 The 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 ```json211 ```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 it313 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 is609 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 is90 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 providers125 ## API-key providers
94 126
95 These providers can use environment variables. Simple API-key providers can127 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`, and512 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, so514+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 do515+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 stored516+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 matching526 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-v2547 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 resolution598 ## 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. When79 bundled skills; bundled skills take precedence over embedded built-ins. When
80 two extensions provide the same skill name, the first declaration wins. The80 two extensions provide the same skill name, the first declaration wins. The
81 host rejects manifest paths that escape the extension directory, including81 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 to103 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 full110 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 skills193 ## Invoking skills
95 194
96 For normal skills, the system prompt tells the model the skill names and195 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.Model283+ 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 nil285 return nil
286 },286 },
@@ -532,3 +532,13 @@ func telegramBotReset() error {
532 fmt.Println("removed", p)532 fmt.Println("removed", p)
533 return nil533 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.CustomProviderConfig71 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 the78 // SkillTool is the on-demand skill loader registered with the
73 // agent's tool registry, or nil if no SKILL.md files were79 // 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]string99 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 while108 // ModelCatalogSnapshot returns the runtime-owned model metadata captured while
100 // resolving. It is used by SDK clients whose provider catalog is scoped to a109 // resolving. It is used by SDK clients whose provider catalog is scoped to a
@@ -235,6 +244,7 @@ type ExtensionToolInfo struct {
235 Description string244 Description string
236 Schema []byte245 Schema []byte
237 Deferred bool246 Deferred bool
247+ Interactive bool
238 }248 }
239 249
240 // toolSummariesFromRegistry rebuilds the system-prompt tool list250 // 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 for358 // 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 = true600 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 string609 cred string
595 method string610 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 bool617 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 dummy626 // 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't636 // 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 the742 // 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 the858 // 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 (local893 // 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 = nil910 credErr = nil
865 requireCred = false911 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.withHTTPClient1185 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.interactive73 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 return80 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 out280 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 err1155 return err
1152 }1156 }
@@ -1184,6 +1188,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
1184 persistCompaction = sess.AppendCompaction1188 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 := start1194 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 err1238 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.AppendCompaction1274 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 := start1278 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 err1360 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.AppendCompaction1399 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 := start1403 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 opened1945 var sessBaselineMsgs int // messages already on disk when current session opened
1938 var sessionTitlePending bool1946 var sessionTitlePending bool
1939 // initialPlanSnapshots is reconstructed from the untrimmed resumed1947 // 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.PlanUpdate1950 var initialPlanSnapshots map[string]core.PlanUpdate
1943 if !args.NoSess && ag != nil {1951 if !args.NoSess && ag != nil {
1944 var sessErr error1952 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 sessErr1955 return sessErr
1948 }1956 }
@@ -2386,6 +2394,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2386 committed = true2394 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 nil2820 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 []string2830+ 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] = true2835+ 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] = true2842+ 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 during2930+ 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 nil3142 // 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 during3146+ 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) ToolResult59 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 extension68 // EventHandler is called for each lifecycle event the extension
61 // subscribed to via Subscribe. The handler is invoked synchronously69 // subscribed to via Subscribe. The handler is invoked synchronously
62 // on the read goroutine; keep it quick or hand off to your own70 // on the read goroutine; keep it quick or hand off to your own
@@ -283,8 +291,9 @@ type Extension struct {
283 mu sync.Mutex291 mu sync.Mutex
284 commands map[string]CommandHandler292 commands map[string]CommandHandler
285 descriptions []descTuple // ordered so register frames arrive in registration order293 descriptions []descTuple // ordered so register frames arrive in registration order
286- tools map[string]ToolHandler294+ tools map[string]InteractiveToolHandler
287- toolDefs []toolDef // ordered so register frames arrive in registration order295+ 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]EventHandler297 eventHandlers map[string]EventHandler
289 eventNames []string // declared subscription order298 eventNames []string // declared subscription order
290 299
@@ -313,6 +322,12 @@ type toolDef struct {
313 description string322 description string
314 schema json.RawMessage323 schema json.RawMessage
315 deferred bool324 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 for333 // 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] = fn465 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 each489 // On subscribes to a lifecycle event. fn is called for each
448 // notification; the same name can only have one handler (later490 // 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 has600 // 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 []string677 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 continue743 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.EventFromHost789 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 nil842 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.ReadCloser110 stdout io.ReadCloser
109 logFile *os.File111 logFile *os.File
110 helloAck bool112 helloAck bool
111- commands []extproto.RegisterCommandFromExt113+ // toolCancel records whether the extension advertised the "tool_cancel"
112- tools []extproto.RegisterToolFromExt114+ // capability in hello. Set during the handshake, before publication.
113- skills []*skills.Skill115+ toolCancel bool
114-116+ commands []extproto.RegisterCommandFromExt
115- writeMu sync.Mutex // serializes concurrent host→extension frames117+ 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's126 // lifecycleFrames decouples session-event delivery from the host's
118 // synchronous control paths. The queue preserves lifecycle ordering while127 // 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 support222 // AlertHostHooks is an optional extension to HostHooks. Hosts that support
196 // structured alerts implement it without breaking older embedders that only223 // 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 = true753 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.SessionContext757 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 = false782 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 so871 // On close, drop every command + tool this extension owned so
832 // future invocations don't dangle. The subprocess is gone; we872 // 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.Millisecond1094 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 shared1111+// 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 string1340 Description string
1284 Schema json.RawMessage1341 Schema json.RawMessage
1285 Deferred bool1342 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 currently1347 // 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 for1395 // InvokeTool sends a tool_call to the owning extension and waits for
1336 // the matching tool_result. Used by the core.Tool wrapper that the1396 // 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] = ch1420 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, nil1469 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 continue1595 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 out1655 return out
1536 }1656 }
1537 1657
1538-// newCorrelationID returns a short non-cryptographic id. We don't1658+var correlationSequence atomic.Uint64
1539-// need uniqueness across processes, just within the lifetime of one1659+
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 *Manager36 manager *Manager
36 timeout time.Duration37 timeout time.Duration
37 deferred bool38 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 through59 // NewTool returns a core.Tool that round-trips invocations through
41-// mgr to the extension that registered (name, schema). The default60+// 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. It88 // 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 the89 // 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 err407 return err
383 }408 }
384- provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL))409+ provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL))
385 return nil410 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 a217 // 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 false35 return false
36 }36 }
37- text := message.Text37+ 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 + 143 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 true96 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) error254 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 the262 // BuildAgent is called after a successful login to (re)construct the
257 // agent with the fresh credential. It returns the new agent and263 // 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.Skill446 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 the462 // ChangelogChan, if non-nil, delivers release-notes for the
443 // current binary version once at startup. Interactive opens a463 // current binary version once at startup. Interactive opens a
444 // dismissible overlay when the channel produces a non-empty464 // dismissible overlay when the channel produces a non-empty
@@ -850,9 +870,14 @@ type Interactive struct {
850 telegramBridge *telegram.Bridge870 telegramBridge *telegram.Bridge
851 sessionOpsDialog *sessionOpsDialog871 sessionOpsDialog *sessionOpsDialog
852 sessionTreeDialog *sessionTreeDialog872 sessionTreeDialog *sessionTreeDialog
873+ timeline *timelineView
853 extPanel *extPanelDialog874 extPanel *extPanelDialog
854 llamaConfigured bool875 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 to881 // 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 decides883 // 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 i1194 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 the225 // 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() != nil227 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 accessor231 // 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()) > 0233 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 = nil245 i.sessionInfoBlocks = nil
243 i.parkedTurn = 0246 i.parkedTurn = 0
244 i.parkedTotal = 0247 i.parkedTotal = 0
248+ i.lastCtxInput = 0
245 i.scrollOffset = 0249 i.scrollOffset = 0
246 i.extNotes = nil250 i.extNotes = nil
247 i.reloadErrors = nil251 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 = true302 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 false523 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 []string561 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 return850 return
807 }851 }
808- // Always run the manual/copy-code flow in parallel with the local852+ // One browser transaction: the callback server and the pasted-code path
809- // callback server so headless environments (docker, SSH) can paste853+ // 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 return857 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 return869 return
832 }870 }
833- i.dialog.url = url871+ 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 return876 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 return953 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-completion958 // Keep the mutex held while enqueueing so the turn-completion
915 // goroutine cannot publish idle and inspect an empty queue between959 // 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.Skill981+ 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.ID1245 i.cfg.Model = m.ID
1206 i.agent.Model = m.ID1246 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.ID1257 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-op1347 // 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 = ag105 i.agent = ag
106+ // Resumed, imported and forked conversations already have context.
107+ i.skillPins.disarm()
106 i.cfg.Provider = providerName108 i.cfg.Provider = providerName
107 i.cfg.Model = model109 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 = nil190 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 return315 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 teardown325 // 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 return388 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 false136 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 false382 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 false473 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 false481 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 = nil571 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 = nil574+ 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 the938 // If a turn is already in flight, queue this prompt inside the
926 // agent loop so it is delivered at the next safe model-call939 // 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 true1047 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 nil1055 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 continue1061 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 continue1068 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 := 0538 dialogRemovedTopRows := 0
@@ -742,10 +745,15 @@ func (i *Interactive) redraw() {
742 // content. The status block and editor get their own dedicated745 // content. The status block and editor get their own dedicated
743 // blanks so spacing stays consistent whether or not a dialog or746 // 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.InputStyleLines757 lineInput := inputStyle == tui.InputStyleLines
750 statusBelow := statusPosition == tui.StatusPositionBelowInput758 statusBelow := statusPosition == tui.StatusPositionBelowInput
751 workingBelow := workingPosition == tui.WorkingPositionBelowInput759 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 the51 // 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 return58 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].ID131 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 = models136 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() is18 // 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 = iota21+ loginStepClosed loginStep = iota
22- loginStepMethod // pick apikey vs subscription22+ loginStepMethod // pick apikey vs subscription
23- loginStepProvider // pick anthropic vs openai vs kimi23+ loginStepProvider // pick anthropic vs openai vs kimi
24- loginStepLlamaURL // enter and validate llama.cpp router URL24+ loginStepOAuthMethod // pick browser or copy-code for Anthropic
25- loginStepLlamaKey // enter optional llama.cpp API key25+ loginStepLlamaURL // enter and validate llama.cpp router URL
26- loginStepWaiting // browser open, waiting for callback26+ loginStepLlamaKey // enter optional llama.cpp API key
27- loginStepPasteCode // user pastes the auth code here27+ loginStepWaiting // browser open, waiting for callback
28- loginStepInfo // informational setup guidance28+ loginStepPasteCode // user pastes the auth code here
29- loginStepDone // success or error, waiting for key to dismiss29+ loginStepInfo // informational setup guidance
30+ loginStepDone // success or error, waiting for key to dismiss
30 )31 )
31 32
32 const loginProviderPageSize = 833 const loginProviderPageSize = 8
@@ -79,6 +80,7 @@ func (d *loginDialog) Open(zutHome string) {
79 d.url = ""80 d.url = ""
80 d.cursor = 081 d.cursor = 0
81 d.providerQuery = ""82 d.providerQuery = ""
83+ d.codeEd = nil
82 d.llamaEd = nil84 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 = loginStepWaiting558 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 = url625 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 view11+// skillsDialog lists every discovered skill, shows its pin scopes, and lets
12-// the body of one inline. View is read-only — the model loads skills12+// 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 bool16 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.Skill19 skills []*skills.Skill
17 cursor int20 cursor int
18 viewing *skills.Skill // when non-nil, render the body instead of the list21 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 out135 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.viewing165 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 starts86 // 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 = 888 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 nil297 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 nil313 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.skills318 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 commands344 // 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 zut26 // runScheduledSession reconstructs an inactive session in the current zut
16 // process and appends the resulting turn to its existing transcript. The27 // 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.Model56 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 are23 // the related ecosystems works in zut unchanged. User skill roots are
24 // walked recursively, and nested files can also be addressed by their24 // 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 skills28 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 errs242 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 continue275 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 errs297 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 out301 // 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 a302 // 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 []error311 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 nil421+ 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 a428 // 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 nil430+ 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 nil434+ }
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 errs439 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 path464 // addRelativeAlias makes a nested skill addressable by the directory path
297 // used by common skill routers, for example465 // 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] = tool358 registry[name] = tool
355 }359 }
356 return registry, nil360 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 continue366 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 continue276 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 string218 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 parties614 // 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 string40 manualEventProvider string
41 manualPKCE PKCE41 manualPKCE PKCE
42 manualState string42 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 = nil74 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 = cs181 m.oauthServer = cs
188 m.oauthCtx = ctx182 m.oauthCtx = ctx
189 m.oauthCancel = cancel183 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 return215 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 return227 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 return253 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 = ctx267 m.oauthCtx = ctx
237 m.oauthCancel = cancel268 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 = ctx300 m.oauthCtx = ctx
272 m.oauthCancel = cancel301 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 = ctx333 m.oauthCtx = ctx
307 m.oauthCancel = cancel334 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 = AnthropicManualOAuth382 op = AnthropicManualOAuth
356 case "openai", "openai-codex":383 case "openai", "openai-codex":
357- op = OpenAIOAuth384+ 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 "", err396 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.manualOp421+ if m.manualOp == nil {
394- storeProvider := m.manualStoreProvider422+ 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 = pastedState427+ 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 err464 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 err468 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 nil471 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 code479 // - a bare authorization code
433 // - a "code#state" pair480 // - 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 = nil546 m.oauthCancel = nil
@@ -491,6 +549,15 @@ func (m *Manager) CancelOAuth() {
491 m.oauthServer.Shutdown()549 m.oauthServer.Shutdown()
492 m.oauthServer = nil550 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 duplicated14 // 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 that15 // 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 the17+// 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|agent19 // - X-Initiator: user|agent
20 // - Openai-Intent: conversation-edits20 // - Openai-Intent: conversation-edits
21-// - Copilot-Vision-Request: true (when images present; not wired21+// - 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 in23 // 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, rewrite179 // 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, but180 // 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 rewrites215 // 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 parity218 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 yet491 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 managers494+ managedModels []Model // ephemeral models exposed by local model managers
495- userModels []Model // highest-precedence models loaded from models.json495+ 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 can499 // 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 []Model507 managedModels []Model
507 userModels []Model508 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.activeSet544 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 = false638 existing.Speculative = false
639+ if existing.Provider == ProviderGitHubCopilot {
640+ configureCopilotModel(&existing) // normalizes a legacy api value
641+ }
632 active[idx] = existing642 active[idx] = existing
633 continue643 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 the708 // 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 := active718 src := active
@@ -719,7 +739,56 @@ func Active() []Model {
719 // User models are a durable, highest-precedence overlay. Apply them739 // User models are a durable, highest-precedence overlay. Apply them
720 // after the live and managed overlays so a catalog refresh cannot740 // 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 local794 // 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 up824 // 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, nil830 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 blocks134 // RepairOrphanedToolResults removes invalid tool_result content blocks
135-// and entire messages that become empty. A result is invalid when its135+// 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 when136+// is no preceding unmatched tool_use with the same ID in the messages.
137-// an earlier result already covers the same ID. Resume tails, compaction137+// 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 API138+// 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 []Content146 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 continue153 continue
157 }154 }
158- resultIDs[tr.CallID] = true155+ 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 fallback66 // 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 provider50 // 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 string53 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 break176 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.BaseURL251 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.API257 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 by254 // 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 tokens256 // Chroma's inherited styles can assign black backgrounds to a few tokens
259 // (notably punctuation/error-ish spans), which looks like random black blocks257 // (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.Builder267 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 += 2291+ 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 continue296 continue
294 case "49":297 case "49":
295 continue298 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 nil347 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{}, err275 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}, nil288 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 string267 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close