mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e666395d47 | ||
|
|
9043baedcf | ||
|
|
4d8c31cf16 | ||
|
|
824ecdbcf9 | ||
|
|
6b9c2f0c08 | ||
|
|
8ef8576df7 | ||
|
|
1c5c6e78a8 | ||
|
|
7f669f4550 | ||
|
|
2b42cd567b | ||
|
|
665bd4b3d0 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.3.0"}
|
||||
{".":"0.4.0"}
|
||||
+60
-4
@@ -6,6 +6,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **aimlapi:** AI/ML API provider with guided onboarding (top-up + key issuance) ([#655](https://github.com/Gitlawb/zero/issues/655)) ([6b9c2f0](https://github.com/Gitlawb/zero/commit/6b9c2f0c083e02bcd3aad68fb7af0501a9e7cd61))
|
||||
* **cli:** show ZERO wordmark on --version ([#673](https://github.com/Gitlawb/zero/issues/673)) ([9acb411](https://github.com/Gitlawb/zero/commit/9acb4113cc3337b3f361a16278e9cf11ca105e34))
|
||||
* **cli:** wire MCP serve WorkspaceRoot and --add-dir scope ([#694](https://github.com/Gitlawb/zero/issues/694)) ([75a78e7](https://github.com/Gitlawb/zero/commit/75a78e715bf23154f69c8c79cb58eb4b535b2a2a))
|
||||
* **npm:** ship the native binary as platform optionalDependencies ([#626](https://github.com/Gitlawb/zero/issues/626)) ([5e1405d](https://github.com/Gitlawb/zero/commit/5e1405d0b7abff5b3ccb3cfdb66d64d6d3322922))
|
||||
* **perf:** emit prompt-prefix hash fingerprint per turn ([#704](https://github.com/Gitlawb/zero/issues/704)) ([1c5c6e7](https://github.com/Gitlawb/zero/commit/1c5c6e78a8a0e228bdf53d7be90934fbce9d98c3))
|
||||
* **providers:** add AI/ML API preset (rebased onto main) ([#621](https://github.com/Gitlawb/zero/issues/621)) ([d66a9dd](https://github.com/Gitlawb/zero/commit/d66a9dda69c32aa59f4bea903cfefe00d4b7adef))
|
||||
* **providers:** refresh MiniMax model coverage ([#665](https://github.com/Gitlawb/zero/issues/665)) ([fa3052a](https://github.com/Gitlawb/zero/commit/fa3052a1422a4ad30a3a6295829564f42dee31a8))
|
||||
* **skills:** discover shared ~/.agents/skills with multi-root skill loading ([#696](https://github.com/Gitlawb/zero/issues/696)) ([7d57999](https://github.com/Gitlawb/zero/commit/7d579996b43741a2e57e3e38fcdd8484fcfc34e9))
|
||||
* **tui:** Ctrl+X leader chords and emacs menu navigation ([#699](https://github.com/Gitlawb/zero/issues/699)) ([7f669f4](https://github.com/Gitlawb/zero/commit/7f669f455021be51319ee3b8298cd48a17f745c7))
|
||||
* **tui:** press up to edit queued messages ([#656](https://github.com/Gitlawb/zero/issues/656)) ([4c986d3](https://github.com/Gitlawb/zero/commit/4c986d327b5ed26338d295c23bea5839681db8d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** make truncateHint rune-safe ([#614](https://github.com/Gitlawb/zero/issues/614)) ([ddc4927](https://github.com/Gitlawb/zero/commit/ddc4927aac5544bf4dd2c46615aeda5a81c96576))
|
||||
* **agent,tui:** resolve git branch detection when starting Zero in subdirectories ([#613](https://github.com/Gitlawb/zero/issues/613)) ([0184581](https://github.com/Gitlawb/zero/commit/0184581ec234ea414e0a47bc33e8b0f4ddfb497b))
|
||||
* **agent:** raise default and deep-mode turn budgets ([#650](https://github.com/Gitlawb/zero/issues/650)) ([635c93a](https://github.com/Gitlawb/zero/commit/635c93af51ebc20f3e0917917e55a79edfe27c35))
|
||||
* **cli:** prevent consuming positional arguments as flag values ([#619](https://github.com/Gitlawb/zero/issues/619)) ([5b4f48d](https://github.com/Gitlawb/zero/commit/5b4f48d2dcb66402c13bde0c3cfe9c9371da19fb))
|
||||
* **config:** enforce MCP trust boundary so project config cannot override user disable ([#609](https://github.com/Gitlawb/zero/issues/609)) ([4d8c31c](https://github.com/Gitlawb/zero/commit/4d8c31cf16a3080344e3be7039408fcae71c075d)), closes [#512](https://github.com/Gitlawb/zero/issues/512)
|
||||
* **config:** surface unknown/typo'd config fields instead of silently dropping them ([#645](https://github.com/Gitlawb/zero/issues/645)) ([893b7b4](https://github.com/Gitlawb/zero/commit/893b7b424cc203a2fcf92327a4e25c84286a90e0))
|
||||
* **cron:** prevent cron job Mutate from clobbering concurrent updates ([#630](https://github.com/Gitlawb/zero/issues/630)) ([e4bd703](https://github.com/Gitlawb/zero/commit/e4bd703cfb28dab2dfa3c2ddba46237e1bb2e164))
|
||||
* **daemon:** handle os.ErrPermission as collision during O_EXCL lock creation ([#616](https://github.com/Gitlawb/zero/issues/616)) ([8ea5384](https://github.com/Gitlawb/zero/commit/8ea53841a5d54c37a153779ae86bea010659433c))
|
||||
* **exec:** stop false INCOMPLETE downgrades on conversational final messages ([#608](https://github.com/Gitlawb/zero/issues/608)) ([b6117af](https://github.com/Gitlawb/zero/commit/b6117af86d6bc87a4ee66910e99d76cb16b03fed))
|
||||
* harden MCP credential boundaries ([#597](https://github.com/Gitlawb/zero/issues/597)) ([fdddb05](https://github.com/Gitlawb/zero/commit/fdddb05ba84b1600ae6c3a20028bf83afe474c44))
|
||||
* **hooks:** fail closed on launch failures for beforeTool hooks ([#629](https://github.com/Gitlawb/zero/issues/629)) ([dc06fe7](https://github.com/Gitlawb/zero/commit/dc06fe72caf45f72d2cba1e8a835c0f5b405c1e8))
|
||||
* **hooks:** run sessionEnd hooks after Esc/Ctrl+C interrupts ([#606](https://github.com/Gitlawb/zero/issues/606)) ([824ecdb](https://github.com/Gitlawb/zero/commit/824ecdbcf9c467c35ef4e2666770fdadcb5bf402))
|
||||
* **keyring:** pass generic password via stdin on macOS ([#574](https://github.com/Gitlawb/zero/issues/574)) ([91ea6de](https://github.com/Gitlawb/zero/commit/91ea6ded7503538834a84d090f78670a363c62d3))
|
||||
* **lock:** prevent POSIX lock file overwrite and leak on Windows/Unix ([#628](https://github.com/Gitlawb/zero/issues/628)) ([da41c3a](https://github.com/Gitlawb/zero/commit/da41c3a75b782d6e0836fe13346321e40a90fbb4))
|
||||
* **openai:** omit prompt_cache_key for openai-compatible providers ([#636](https://github.com/Gitlawb/zero/issues/636)) ([1af5882](https://github.com/Gitlawb/zero/commit/1af58828eb3c22567599c000736c913a290959d2)), closes [#624](https://github.com/Gitlawb/zero/issues/624)
|
||||
* **plugins:** resolve relative executable paths against plugin root ([#627](https://github.com/Gitlawb/zero/issues/627)) ([2efe6d5](https://github.com/Gitlawb/zero/commit/2efe6d539e29374b3ef39c2290bdea81f33a228b))
|
||||
* **sandbox:** remove windowsWriteRestricted flag to fix DenyRead bypass ([#612](https://github.com/Gitlawb/zero/issues/612)) ([3d96ac7](https://github.com/Gitlawb/zero/commit/3d96ac7e55c760a97f28c0e6ceaf1ec3b4ab717a))
|
||||
* **sandbox:** scrub dynamic credential env vars ([#682](https://github.com/Gitlawb/zero/issues/682)) ([9043bae](https://github.com/Gitlawb/zero/commit/9043baedcff7776c7373645b57563cac06b31847))
|
||||
* **sandbox:** scrub sensitive credentials from sandbox environment ([#660](https://github.com/Gitlawb/zero/issues/660)) ([6fc1220](https://github.com/Gitlawb/zero/commit/6fc1220f6ac66fb3ae67b637cbbed7068d2213c0))
|
||||
* **sandbox:** unblock git fetch/commit/add under the write-restricted sandbox ([#654](https://github.com/Gitlawb/zero/issues/654)) ([5c4815a](https://github.com/Gitlawb/zero/commit/5c4815a66ed07d9cf90b825adfd936d3ac07639d))
|
||||
* **sandbox:** use WRITE_RESTRICTED token when no DenyRead paths are configured ([#658](https://github.com/Gitlawb/zero/issues/658)) ([a5d2e32](https://github.com/Gitlawb/zero/commit/a5d2e327c8681671aa8a9e5378801215b747edcf))
|
||||
* **securefile,credstore:** call Sync on temp file before close and rename ([#631](https://github.com/Gitlawb/zero/issues/631)) ([212734a](https://github.com/Gitlawb/zero/commit/212734adf3b5f982e22385161205aa1afe4634fe))
|
||||
* **securefile:** reclaim stale lock files to prevent permanent DOS ([#615](https://github.com/Gitlawb/zero/issues/615)) ([8536cc8](https://github.com/Gitlawb/zero/commit/8536cc87f7a885f9e436d6ef28f5f325201623dc))
|
||||
* **swarm:** wait for job.Runs directly in scheduler skip test ([#667](https://github.com/Gitlawb/zero/issues/667)) ([1bb6b57](https://github.com/Gitlawb/zero/commit/1bb6b5745af90321d1e657a12a1976cded5dd1bd))
|
||||
* **tools:** classify silent wrapped Windows command failures as sandbox denials ([#659](https://github.com/Gitlawb/zero/issues/659)) ([8bd9742](https://github.com/Gitlawb/zero/commit/8bd9742fa95c41b93ea4e718628aed0ff3ae9dd0))
|
||||
* **tools:** preserve SysProcAttr during PTY fallback ([#618](https://github.com/Gitlawb/zero/issues/618)) ([f78b36c](https://github.com/Gitlawb/zero/commit/f78b36c770daa4577a9f99265b18a354454e36eb))
|
||||
* **tui:** resolve pending askUser callbacks to prevent runner hangs ([#620](https://github.com/Gitlawb/zero/issues/620)) ([aa73a76](https://github.com/Gitlawb/zero/commit/aa73a76f1bd1b6fe97bac2fbff2d61b7474139f2))
|
||||
* **tui:** stop the composer cursor blinking while typing or unfocused ([#672](https://github.com/Gitlawb/zero/issues/672)) ([2b42cd5](https://github.com/Gitlawb/zero/commit/2b42cd567b96d6f7e0818594e53ff31cce1e42e9))
|
||||
* **windows:** resolve absolute path for taskkill to prevent hijacking ([#617](https://github.com/Gitlawb/zero/issues/617)) ([2db00ee](https://github.com/Gitlawb/zero/commit/2db00ee3d57db97e0fbe23cb8628e8bcb47f6f09))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **tools:** add explicit effect metadata for safe concurrency ([#705](https://github.com/Gitlawb/zero/issues/705)) ([8ef8576](https://github.com/Gitlawb/zero/commit/8ef8576df7d0775a5b815bc0776a794cadb75c34))
|
||||
|
||||
## [0.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
|
||||
|
||||
|
||||
@@ -134,10 +188,12 @@ tagged. Until then, source builds report the version `dev`.
|
||||
GitHub issue/PR templates.
|
||||
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
|
||||
and applies on select (Esc reverts).
|
||||
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
|
||||
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
|
||||
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
|
||||
- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`,
|
||||
`solarized-light`, `dune`, and `neon` — selectable via `/theme <name>`, `--theme <name>`, or
|
||||
`ZERO_THEME`. Every palette is contrast-audited to WCAG AA, and the new presets are additionally
|
||||
audited after xterm-256 downsampling; see [docs/THEMES.md](docs/THEMES.md). The built-in light
|
||||
theme was reworked for legibility.
|
||||
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
|
||||
`ZERO_THEME` env var existed).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
|
||||
@@ -191,6 +191,8 @@ Useful controls:
|
||||
|---|---|
|
||||
| `Enter` | send the prompt |
|
||||
| `/` | open slash-command suggestions |
|
||||
| `Ctrl+X` then letter | common slash commands (e.g. `m` → `/model`; `Ctrl+X` `?` for full list) |
|
||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel or exit |
|
||||
@@ -339,7 +341,7 @@ manifest.
|
||||
| Control | Effect |
|
||||
|---|---|
|
||||
| `NO_COLOR=<anything>` | disables color output |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `neon`, `solarized-light`, `dune`) |
|
||||
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
|
||||
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
|
||||
| `ZERO_NO_FADE=1` | disables streaming fade animation |
|
||||
@@ -386,6 +388,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
|
||||
- [Install](docs/INSTALL.md)
|
||||
- [Update flow](docs/UPDATE.md)
|
||||
- [Themes](docs/THEMES.md)
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [Specialists](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
|
||||
+2
-1
@@ -234,7 +234,7 @@ zero update 检查更新版本
|
||||
| 控制 | 效果 |
|
||||
|---|---|
|
||||
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
|
||||
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
|
||||
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) |
|
||||
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
|
||||
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
|
||||
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
|
||||
@@ -261,6 +261,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
|
||||
- [安装](docs/INSTALL.md)
|
||||
- [更新流程](docs/UPDATE.md)
|
||||
- [主题](docs/THEMES.md)
|
||||
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [专家](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# TUI Themes
|
||||
|
||||
Zero's TUI ships a set of built-in color themes. Pick one with `--theme <name>`,
|
||||
the `ZERO_THEME` environment variable, or the `/theme <name>` command while
|
||||
running. `auto` (the default) follows the terminal's detected background.
|
||||
|
||||
Run `/theme` with no argument to open a picker: move through the list to
|
||||
live-preview each theme, press Enter to apply the highlighted one, or Esc to
|
||||
cancel and restore the previously active theme. Run `/theme list` to print
|
||||
the active theme and the registered names without opening the picker.
|
||||
|
||||
## Dune (`dune`)
|
||||
|
||||
A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft
|
||||
amber accent.
|
||||
|
||||
## Neon (`neon`)
|
||||
|
||||
A neon-on-black palette: pitch-black surface, bright green ink, and a cyan accent.
|
||||
|
||||
## Adding a theme
|
||||
|
||||
Every theme is a `palette` (a table of color hex tokens) plus one entry in
|
||||
`themeRegistry`, both in `internal/tui/theme_palettes.go`. `buildTheme` in
|
||||
`internal/tui/theme.go` turns a `palette` into the resolved `lipgloss.Style`
|
||||
set every renderer reads from the active `zeroTheme`. Adding a theme means
|
||||
adding a new `palette{...}` literal, a `themeRegistry` entry, and test
|
||||
coverage for the new palette (see below).
|
||||
|
||||
Registry-wide tests in `internal/tui/theme_select_test.go` assert the basic
|
||||
WCAG AA text tokens, the gray-ramp order, the diff word-span pairs, and the
|
||||
selected-row band for every entry. The rendered-surface invariants beyond
|
||||
those (permission surfaces, selected-row secondary text, diff gutters, and
|
||||
the xterm-256 downsampling checks) are asserted per palette, not against the
|
||||
whole registry: `TestExtendedThemeContrastInvariants` and
|
||||
`TestExtendedThemeANSI256Contrast` enumerate the palettes they cover. A new
|
||||
theme must be added to those tests (or given equivalent palette-specific
|
||||
assertions), or CI can stay green while its permission, selected-row, and
|
||||
diff surfaces ship unreadable.
|
||||
+25
-1
@@ -185,7 +185,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result = Result{Messages: copyMessages(messages)}
|
||||
dispatchSessionStart(ctx, options)
|
||||
defer func() {
|
||||
dispatchSessionEnd(ctx, options, result, err)
|
||||
// Use a context detached from ctx's cancellation: on Esc/Ctrl+C the run
|
||||
// context is already canceled by the time this defer runs, and a canceled
|
||||
// parent would make Hooks.Dispatch's context.WithTimeout derive an
|
||||
// already-expired context, so the sessionEnd hook would never actually run.
|
||||
dispatchSessionEnd(context.WithoutCancel(ctx), options, result, err)
|
||||
}()
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
@@ -213,6 +217,26 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
toolPartitionSpan.End()
|
||||
|
||||
// Prompt-prefix fingerprint: hash the seven cacheable sub-components
|
||||
// of this turn's request (system prompt sections, project context,
|
||||
// skills, partitioned tool list, tool schemas) and emit one trace
|
||||
// event. Stable across turns => cacheable. Drift in any sub-hash
|
||||
// names the sub-component that broke the cache for this turn. The
|
||||
// computation is pure and cheap (seven SHA-256s of small strings)
|
||||
// and is a no-op when tracing is off.
|
||||
if options.Trace != nil {
|
||||
fp := ComputePrefixFingerprint(options, exposed)
|
||||
options.Trace.EmitPrefixHash(trace.PrefixHash{
|
||||
BaseInstructionsHash: fp.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fp.ProjectContextHash,
|
||||
SkillsHash: fp.SkillsHash,
|
||||
ToolsHash: fp.ToolsHash,
|
||||
SchemaHash: fp.SchemaHash,
|
||||
CompletePrefixHash: fp.CompletePrefixHash,
|
||||
})
|
||||
}
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
// request. A no-op when ContextWindow == 0 (compaction disabled).
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -93,6 +94,87 @@ func TestRunDispatchesSessionLifecycleHooks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// cancelingProvider simulates an Esc/Ctrl+C abort: it cancels the run's own
|
||||
// context mid-stream (as the TUI's interrupt handler would) and returns no
|
||||
// events, so Run observes ctx.Err() and returns early with an already-canceled
|
||||
// context in scope for the deferred sessionEnd dispatch.
|
||||
type cancelingProvider struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (provider *cancelingProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.cancel()
|
||||
ch := make(chan zeroruntime.StreamEvent)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunDispatchesSessionEndHookAfterContextCancellation is a regression test
|
||||
// for a bug where an interrupted run (Esc/Ctrl+C) canceled ctx before the
|
||||
// deferred sessionEnd dispatch ran, so Hooks.Dispatch's context.WithTimeout(ctx, ...)
|
||||
// derived an already-canceled context and the hook command never actually launched.
|
||||
func TestRunDispatchesSessionEndHookAfterContextCancellation(t *testing.T) {
|
||||
goBinary, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
// The test binary runs on the build machine, so its build-time GOROOT
|
||||
// still identifies the toolchain that produced it.
|
||||
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
|
||||
goBinary = filepath.Join(goRoot, "bin", "go")
|
||||
if runtime.GOOS == "windows" {
|
||||
goBinary += ".exe"
|
||||
}
|
||||
if _, statErr := os.Stat(goBinary); statErr != nil {
|
||||
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
|
||||
}
|
||||
}
|
||||
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: goBinary, Args: []string{"version"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
provider := &cancelingProvider{cancel: cancel}
|
||||
|
||||
_, err = Run(ctx, "hi", provider, Options{
|
||||
SessionID: "session-cancel",
|
||||
Cwd: t.TempDir(),
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run err = %v, want context.Canceled", err)
|
||||
}
|
||||
|
||||
events, err := audit.ReadEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
var completedStatus hooks.AuditStatus
|
||||
found := false
|
||||
for _, event := range events {
|
||||
if event.Type == "hook_execution_completed" && event.Event == hooks.EventSessionEnd {
|
||||
completedStatus = event.Status
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no sessionEnd hook_execution_completed audit event, events=%#v", events)
|
||||
}
|
||||
if completedStatus != hooks.AuditCompleted {
|
||||
t.Fatalf("sessionEnd hook status = %q, want %q (hook must actually run despite ctx cancellation)", completedStatus, hooks.AuditCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingWebSearchTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// promptSubstrings are the seven cacheable sub-components of the prompt a run
|
||||
// sends to a model. Each is the literal content the sub-component would emit
|
||||
// into the system prompt or tool list, hashed so a downstream observer can
|
||||
// detect drift turn-over-turn. The substrings are produced by
|
||||
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
|
||||
type promptSubstrings struct {
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
skills string
|
||||
tools string
|
||||
schema string
|
||||
}
|
||||
|
||||
// buildPromptSubstrings assembles the seven cacheable sub-components of the
|
||||
// prompt without joining them. The system prompt sections are produced by the
|
||||
// same private builders that buildSystemPrompt uses, so the substrings are
|
||||
// byte-identical to what would have appeared in the joined prompt. The tools
|
||||
// and schema substrings are derived from the partitioned tool list (caller-
|
||||
// provided) so this helper does not need to re-run the partition.
|
||||
//
|
||||
// This is the seam ComputePrefixFingerprint reads from. It exists as a
|
||||
// separate function (rather than returning both prompt and substrings from
|
||||
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
|
||||
// and the substrings helper is independently testable.
|
||||
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
core := strings.TrimSpace(options.SystemPrompt)
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(coreSystemPrompt)
|
||||
}
|
||||
if core == "" {
|
||||
core = fallbackSystemPrompt
|
||||
}
|
||||
|
||||
// The confirmation policy is appended unconditionally in buildSystemPrompt;
|
||||
// the substring here is the same constant.
|
||||
policy := strings.TrimSpace(confirmationPolicy)
|
||||
|
||||
// projectContext is the joined output of workspaceContext, which itself
|
||||
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
|
||||
// joined string captures every guideline file's contribution and the
|
||||
// ordering between them.
|
||||
project := workspaceContext(options.Cwd)
|
||||
|
||||
// skills is the joined output of skillsContext, with each skill on its own
|
||||
// line. The substring is the literal text the model would see.
|
||||
skills := skillsContext(options)
|
||||
|
||||
// tools and schema are derived from the partitioned tool list. The list
|
||||
// passed in is already in stable order (partitionToolsCached is the
|
||||
// canonical stable partitioner) so concatenating in slice order is
|
||||
// sufficient — we sort names again defensively in case a future caller
|
||||
// hands us a list from a different partitioner.
|
||||
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
||||
|
||||
return promptSubstrings{
|
||||
baseInstructions: core,
|
||||
confirmationPolicy: policy,
|
||||
projectContext: project,
|
||||
skills: skills,
|
||||
tools: toolsSubstr,
|
||||
schema: schemaSubstr,
|
||||
}
|
||||
}
|
||||
|
||||
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
|
||||
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
|
||||
// the partition produces a different hash. The schema substring is the
|
||||
// concatenation of each tool's JSON schema in name order, so a schema change
|
||||
// is detected independently of the tool ordering.
|
||||
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
||||
if len(exposed) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
names := make([]string, 0, len(exposed))
|
||||
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
|
||||
for _, def := range exposed {
|
||||
names = append(names, def.Name)
|
||||
byName[def.Name] = def
|
||||
}
|
||||
sort.Strings(names)
|
||||
var toolsSB, schemaSB strings.Builder
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
toolsSB.WriteByte('\n')
|
||||
}
|
||||
toolsSB.WriteString(name)
|
||||
def := byName[name]
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
schemaSB.WriteString(name)
|
||||
schemaSB.WriteByte('\n')
|
||||
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
|
||||
schemaSB.WriteByte('\n')
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
||||
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
||||
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
||||
// a different hash for the same Parameters value across calls and defeat
|
||||
// the fingerprint. encoding/json marshals maps with keys sorted
|
||||
// alphabetically, so json.Marshal is the primary stable render. The
|
||||
// fallback for the rare non-JSON-compatible value (functions, channels,
|
||||
// NaN/Inf floats, cyclic references) is a stable key-sorted stringifier
|
||||
// that walks the value with sorted keys for maps and a leading
|
||||
// "__non_json:" prefix so a future schema change is visible in the trace
|
||||
// (a SchemaHash collision from the same value) rather than a silent hash
|
||||
// drift.
|
||||
//
|
||||
// Note: SchemaHash is a stability signal, not a wire-identity signal. The
|
||||
// bytes json.Marshal produces for a Go map[string]any may differ from
|
||||
// the bytes the provider actually sends on the wire (provider encoders
|
||||
// use their own JSON conventions; an Anthropic schema may serialize with
|
||||
// different whitespace, key order, or number formatting). A consumer
|
||||
// must NOT assume SchemaHash matches the provider's wire schema — only
|
||||
// that two turns with the same Go-side Parameters produce the same
|
||||
// SchemaHash. This is sufficient for the trace's purpose (drift
|
||||
// detection) but not for a content-based equality check.
|
||||
func canonicalSchemaString(params map[string]any) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return "__non_json:" + stableStringify(params)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// stableStringify renders v to a deterministic string. Maps have their keys
|
||||
// sorted alphabetically; slices are walked in order; primitives use their
|
||||
// natural Go format. The function is recursive but bounded by the size of
|
||||
// the input, so a cyclic reference is not reachable here (the json.Marshal
|
||||
// fallback is hit when json.Marshal itself returns an error, which for the
|
||||
// common case is a non-JSON-compatible value, not a cycle — cycles are
|
||||
// rare in tool schemas and acceptable to mis-render in the fallback path
|
||||
// since the trace's contract is "the hash is stable for the same input,"
|
||||
// not "the fallback is lossless"). Used only when json.Marshal fails.
|
||||
func stableStringify(v any) string {
|
||||
var sb strings.Builder
|
||||
writeStable(&sb, v)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeStable(sb *strings.Builder, v any) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
sb.WriteString("null")
|
||||
case bool:
|
||||
if x {
|
||||
sb.WriteString("true")
|
||||
} else {
|
||||
sb.WriteString("false")
|
||||
}
|
||||
case string:
|
||||
sb.WriteByte('"')
|
||||
sb.WriteString(x)
|
||||
sb.WriteByte('"')
|
||||
case float64:
|
||||
// Use %g for compact, deterministic float rendering. NaN and Inf are
|
||||
// not JSON-encodable (which is why we are in the fallback path) and
|
||||
// render as "NaN" / "+Inf" / "-Inf" — distinct, stable strings.
|
||||
fmt.Fprintf(sb, "%g", x)
|
||||
case float32:
|
||||
fmt.Fprintf(sb, "%g", float64(x))
|
||||
case int:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case int64:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case int32:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case uint:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case uint64:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case uint32:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case map[string]any:
|
||||
sb.WriteByte('{')
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteByte('"')
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(`":`)
|
||||
writeStable(sb, x[k])
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
case []any:
|
||||
sb.WriteByte('[')
|
||||
for i, item := range x {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
writeStable(sb, item)
|
||||
}
|
||||
sb.WriteByte(']')
|
||||
default:
|
||||
// Last-resort: include the type so distinct values produce distinct
|
||||
// strings even if their default format collides. Without the type
|
||||
// tag, fmt.Sprintf("%v", x) for two different types could produce
|
||||
// the same bytes (rare, but the type prefix makes it impossible).
|
||||
fmt.Fprintf(sb, "<%T:%v>", x, x)
|
||||
}
|
||||
}
|
||||
|
||||
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
|
||||
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
|
||||
// independent SHA-256s of the corresponding sub-component; the complete-prefix
|
||||
// hash is a SHA-256 of the canonical concatenation of the other six, so any
|
||||
// sub-component drift is observable both individually and in aggregate.
|
||||
//
|
||||
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
|
||||
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
|
||||
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
|
||||
// context work is performed separately by buildSystemPrompt a few lines
|
||||
// later in the request-build path, so on every traced turn the file and
|
||||
// git reads happen twice. The duplication is intentional for this PR (a
|
||||
// follow-up will pass substrings through) and is a known perf cost only on
|
||||
// the opt-in trace path.
|
||||
//
|
||||
// A stable CompletePrefixHash across turns means the four captured
|
||||
// sub-components (baseInstructions, confirmationPolicy, projectContext,
|
||||
// skills) are byte-identical. It does NOT rule out drift in the seven
|
||||
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
|
||||
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
|
||||
// userGuidelines, specialistDelegationContext, responseStyleContext). For
|
||||
// default Options the four captured substrings are the full prompt; for
|
||||
// non-default Options the seven uncaptured sections may contribute, and a
|
||||
// consumer correlating CompletePrefix stability with cached_input_tokens
|
||||
// must cross-check the model_switches counter (modelPromptAddendum changes
|
||||
// on a model switch) to disambiguate "no drift" from "drift in an
|
||||
// uncaptured section."
|
||||
|
||||
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
||||
subs := buildPromptSubstrings(options, exposed)
|
||||
base := sha256hex(subs.baseInstructions)
|
||||
policy := sha256hex(subs.confirmationPolicy)
|
||||
project := sha256hex(subs.projectContext)
|
||||
skills := sha256hex(subs.skills)
|
||||
toolsH := sha256hex(subs.tools)
|
||||
schema := sha256hex(subs.schema)
|
||||
complete := sha256hex(strings.Join([]string{
|
||||
base, policy, project, skills, toolsH, schema,
|
||||
}, "|"))
|
||||
return prefixFingerprint{
|
||||
BaseInstructionsHash: base,
|
||||
ConfirmationPolicyHash: policy,
|
||||
ProjectContextHash: project,
|
||||
SkillsHash: skills,
|
||||
ToolsHash: toolsH,
|
||||
SchemaHash: schema,
|
||||
CompletePrefixHash: complete,
|
||||
}
|
||||
}
|
||||
|
||||
// prefixFingerprint is the agent-side shape of a prompt-prefix fingerprint. It
|
||||
// is converted to a trace.PrefixHash at the loop boundary (see EmitPrefixHash
|
||||
// in loop.go) so the trace package does not need to import this one. The field
|
||||
// names match the trace.PrefixHash JSON tags 1:1.
|
||||
type prefixFingerprint struct {
|
||||
BaseInstructionsHash string
|
||||
ConfirmationPolicyHash string
|
||||
ProjectContextHash string
|
||||
SkillsHash string
|
||||
ToolsHash string
|
||||
SchemaHash string
|
||||
CompletePrefixHash string
|
||||
}
|
||||
|
||||
// sha256hex returns the hex-encoded SHA-256 of s. Empty input produces the
|
||||
// hash of the empty string, which is a constant; callers that want to
|
||||
// distinguish "absent" from "empty" should check s before calling.
|
||||
func sha256hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property
|
||||
// the trace is meant to expose: two ComputePrefixFingerprint calls over the
|
||||
// same Options and the same exposed tool list produce byte-identical hashes.
|
||||
// This is the regression catch for "did anyone introduce non-determinism into
|
||||
// the prompt prefix."
|
||||
func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
exposed := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
|
||||
{Name: "grep", Parameters: map[string]any{"type": "object"}},
|
||||
}
|
||||
first := ComputePrefixFingerprint(opts, exposed)
|
||||
second := ComputePrefixFingerprint(opts, exposed)
|
||||
if first != second {
|
||||
t.Fatalf("fingerprint must be stable across calls with the same input:\n first=%+v\n second=%+v", first, second)
|
||||
}
|
||||
if first.CompletePrefixHash == "" {
|
||||
t.Fatalf("CompletePrefixHash must be non-empty for a non-trivial prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
|
||||
// name-set sensitivity of the ToolsHash. The test name used to read as
|
||||
// "reorder changes hash" but the assertion is the opposite: the hash is
|
||||
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
|
||||
// names before hashing). The partitioner is expected to produce a stable
|
||||
// order, so a name-order change is not a hash signal — a name-set change
|
||||
// is. The test asserts both: permutation of the same tools produces the
|
||||
// same hash, and adding a tool produces a different hash.
|
||||
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
a := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
}
|
||||
b := []zeroruntime.ToolDefinition{
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
}
|
||||
// toolSubstrings sorts names internally, so the ToolsHash is identical
|
||||
// for a permutation of the same tools. We assert that here to document
|
||||
// the contract: name-order independence, name-set sensitivity.
|
||||
fpA := ComputePrefixFingerprint(opts, a)
|
||||
fpB := ComputePrefixFingerprint(opts, b)
|
||||
if fpA.ToolsHash != fpB.ToolsHash {
|
||||
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||
}
|
||||
// A different tool set must produce a different ToolsHash.
|
||||
c := append([]zeroruntime.ToolDefinition{}, a...)
|
||||
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
|
||||
fpC := ComputePrefixFingerprint(opts, c)
|
||||
if fpA.ToolsHash == fpC.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSchemaChangeChangesSchemaHash asserts that a
|
||||
// change to a tool's Parameters schema is visible in the SchemaHash. A schema
|
||||
// edit that should be cache-stable (e.g. field reorder, doc tweak) will move
|
||||
// the hash and surface in the trace; a schema edit that legitimately
|
||||
// invalidates the cache also moves the hash, which is the right behavior.
|
||||
func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
a := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"version": "v1"}},
|
||||
}
|
||||
b := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"version": "v2"}},
|
||||
}
|
||||
fpA := ComputePrefixFingerprint(opts, a)
|
||||
fpB := ComputePrefixFingerprint(opts, b)
|
||||
if fpA.SchemaHash == fpB.SchemaHash {
|
||||
t.Fatalf("SchemaHash must change when tool Parameters change: a=%s b=%s", fpA.SchemaHash, fpB.SchemaHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
||||
// model addendum change) must move the base-instructions hash, which moves
|
||||
// the complete-prefix hash, which is what the trace is for.
|
||||
func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T) {
|
||||
optsA := Options{Cwd: t.TempDir(), SystemPrompt: "core v1"}
|
||||
optsB := Options{Cwd: t.TempDir(), SystemPrompt: "core v2"}
|
||||
exposed := []zeroruntime.ToolDefinition{{Name: "noop", Parameters: map[string]any{}}}
|
||||
fpA := ComputePrefixFingerprint(optsA, exposed)
|
||||
fpB := ComputePrefixFingerprint(optsB, exposed)
|
||||
if fpA.BaseInstructionsHash == fpB.BaseInstructionsHash {
|
||||
t.Fatalf("BaseInstructionsHash must change when SystemPrompt changes: a=%s b=%s", fpA.BaseInstructionsHash, fpB.BaseInstructionsHash)
|
||||
}
|
||||
if fpA.CompletePrefixHash == fpB.CompletePrefixHash {
|
||||
t.Fatalf("CompletePrefixHash must change when any sub-hash changes: a=%s b=%s", fpA.CompletePrefixHash, fpB.CompletePrefixHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
|
||||
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
|
||||
// This is the regression catch for "did anyone reorder or drop a sub-hash
|
||||
// from the canonical join without updating CompletePrefixHash."
|
||||
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
||||
fp := ComputePrefixFingerprint(opts, exposed)
|
||||
// Manually compute what the canonical join should be.
|
||||
expected := sha256hex(strings.Join([]string{
|
||||
fp.BaseInstructionsHash,
|
||||
fp.ConfirmationPolicyHash,
|
||||
fp.ProjectContextHash,
|
||||
fp.SkillsHash,
|
||||
fp.ToolsHash,
|
||||
fp.SchemaHash,
|
||||
}, "|"))
|
||||
if fp.CompletePrefixHash != expected {
|
||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
|
||||
// substrings helper for default Options: which substrings are non-empty,
|
||||
// which are empty, and that the substring-to-hash round-trip is lossless.
|
||||
func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
||||
opts := Options{
|
||||
Cwd: t.TempDir(),
|
||||
SystemPrompt: "test core",
|
||||
}
|
||||
subs := buildPromptSubstrings(opts, nil)
|
||||
// Invariants the trace depends on (default Options):
|
||||
// 1. baseInstructions substring equals the core system prompt bytes.
|
||||
// 2. confirmationPolicy substring is non-empty (the embedded policy
|
||||
// is always present, post TrimSpace).
|
||||
// 3. skills, tools, and schema substrings are empty (no skills or
|
||||
// tools configured for default Options).
|
||||
if subs.baseInstructions != "test core" {
|
||||
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
||||
}
|
||||
if subs.confirmationPolicy == "" {
|
||||
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
||||
}
|
||||
if subs.skills != "" {
|
||||
t.Fatalf("skills substring must be empty for default Options: got %q", subs.skills)
|
||||
}
|
||||
// Round-trip: the corresponding fingerprint hashes must equal
|
||||
// sha256hex of the substrings, with no truncation or reformatting
|
||||
// between the two layers.
|
||||
fp := ComputePrefixFingerprint(opts, nil)
|
||||
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
||||
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
||||
}
|
||||
if fp.ConfirmationPolicyHash != sha256hex(subs.confirmationPolicy) {
|
||||
t.Fatalf("ConfirmationPolicyHash in fingerprint must equal sha256hex of the substring")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalSchemaStringStableForMapParameters asserts the headline
|
||||
// property the trace needs: the same map[string]any produces the same
|
||||
// canonical string across calls, even though Go's fmt.Sprintf("%v", m)
|
||||
// iterates a map in random order. json.Marshal sorts map keys
|
||||
// alphabetically, which is the fix.
|
||||
func TestCanonicalSchemaStringStableForMapParameters(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"description": "read a file",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"path"},
|
||||
"type": "object",
|
||||
}
|
||||
first := canonicalSchemaString(params)
|
||||
second := canonicalSchemaString(params)
|
||||
if first != second {
|
||||
t.Fatalf("canonicalSchemaString must be stable across calls for the same Parameters:\n first=%s\n second=%s", first, second)
|
||||
}
|
||||
if first == "" {
|
||||
t.Fatal("canonicalSchemaString must produce a non-empty string for a non-empty map")
|
||||
}
|
||||
// Two maps with the same key/value pairs (different declaration
|
||||
// order in source) must produce identical canonical strings. This
|
||||
// is the property that defeats Go's map iteration randomization.
|
||||
reordered := map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"path"},
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
"description": "read a file",
|
||||
}
|
||||
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
|
||||
t.Fatal("canonicalSchemaString must produce identical output for maps with the same key/value pairs in different declaration orders")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalSchemaStringFallbackStableForNonJSONValue exercises the
|
||||
// non-JSON fallback in canonicalSchemaString: when Parameters contains a
|
||||
// value json.Marshal cannot encode (here, a channel), the function must
|
||||
// fall back to a key-sorted stringification that is stable across calls
|
||||
// and across map-iteration order. The previous fallback used
|
||||
// fmt.Sprintf("%v", params), which iterates the map in random order and
|
||||
// produced a different hash for the same value across calls — defeating
|
||||
// the fingerprint. The current fallback is stable by construction.
|
||||
func TestCanonicalSchemaStringFallbackStableForNonJSONValue(t *testing.T) {
|
||||
ch := make(chan int) // channels are not JSON-encodable
|
||||
params := map[string]any{
|
||||
"description": "a tool with a non-JSON value",
|
||||
"channel": ch,
|
||||
"z_first": 1,
|
||||
"a_second": 2,
|
||||
}
|
||||
first := canonicalSchemaString(params)
|
||||
second := canonicalSchemaString(params)
|
||||
if first == "" {
|
||||
t.Fatal("canonicalSchemaString must produce a non-empty string for the fallback path")
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("canonicalSchemaString fallback must be stable across calls:\n first=%s\n second=%s", first, second)
|
||||
}
|
||||
// A re-ordered map (same key/value pairs, different declaration order)
|
||||
// must produce the same fallback string. This is the property the
|
||||
// key-sorted stringifier provides that fmt.Sprintf("%v", m) does not.
|
||||
reordered := map[string]any{
|
||||
"a_second": 2,
|
||||
"z_first": 1,
|
||||
"channel": ch,
|
||||
"description": "a tool with a non-JSON value",
|
||||
}
|
||||
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
|
||||
t.Fatalf("canonicalSchemaString fallback must produce identical output for maps with the same key/value pairs in different declaration orders:\n first=%s\n second=%s", first, canonicalSchemaString(reordered))
|
||||
}
|
||||
// The fallback must include the "__non_json:" prefix so a consumer
|
||||
// can tell the bytes are not a JSON-marshaled schema (a hash collision
|
||||
// between a json.Marshal result and a stableStringify result is
|
||||
// possible in theory; the prefix makes the source observable).
|
||||
if !strings.HasPrefix(first, "__non_json:") {
|
||||
t.Fatalf("canonicalSchemaString fallback must start with the __non_json: prefix, got: %s", first)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PaymentMethod selects how a partner-checkout top-up is paid for.
|
||||
type PaymentMethod string
|
||||
|
||||
const (
|
||||
PaymentMethodCard PaymentMethod = "card"
|
||||
PaymentMethodCrypto PaymentMethod = "crypto"
|
||||
)
|
||||
|
||||
// SessionStatus is the lifecycle state of a partner-checkout session, as reported
|
||||
// by the aimlapi.com backend while polling for payment.
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
SessionStatusPendingAuth SessionStatus = "pending_auth"
|
||||
SessionStatusPendingPayment SessionStatus = "pending_payment"
|
||||
SessionStatusPaid SessionStatus = "paid"
|
||||
SessionStatusExchanging SessionStatus = "exchanging"
|
||||
SessionStatusExchanged SessionStatus = "exchanged"
|
||||
SessionStatusCancelled SessionStatus = "cancelled"
|
||||
SessionStatusExpired SessionStatus = "expired"
|
||||
SessionStatusFailed SessionStatus = "failed"
|
||||
)
|
||||
|
||||
// AuthResult is the bearer token minted by a passwordless sign-in / sign-up.
|
||||
type AuthResult struct {
|
||||
Token string `json:"token"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// PartnerCheckoutSession is a partner-attributed top-up session; its SessionToken
|
||||
// addresses it on later pay/exchange/poll calls.
|
||||
type PartnerCheckoutSession struct {
|
||||
ID string `json:"id"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
PartnerID string `json:"partnerId"`
|
||||
PartnerName *string `json:"partnerName"`
|
||||
UserID *int64 `json:"userId"`
|
||||
AmountUSDMinor *int64 `json:"amountUsdMinor"`
|
||||
Status SessionStatus `json:"status"`
|
||||
IssuedKeyID *string `json:"issuedKeyId"`
|
||||
ReturnURL *string `json:"returnUrl"`
|
||||
}
|
||||
|
||||
// PaymentSession is the hosted payment-provider checkout; PayURL is the page the
|
||||
// user opens to pay.
|
||||
type PaymentSession struct {
|
||||
ProviderSessionID string `json:"providerSessionId"`
|
||||
PayURL string `json:"payUrl"`
|
||||
}
|
||||
|
||||
// PayResult pairs the hosted checkout with the updated partner-checkout session
|
||||
// returned when a top-up payment is initiated.
|
||||
type PayResult struct {
|
||||
Checkout PaymentSession `json:"checkout"`
|
||||
PartnerCheckout PartnerCheckoutSession `json:"partnerCheckout"`
|
||||
}
|
||||
|
||||
// ExchangeResult holds the API key minted when a paid session is exchanged.
|
||||
type ExchangeResult struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
APIKeyID string `json:"apiKeyId"`
|
||||
}
|
||||
|
||||
// TopUpByKeyRequest funds a partner-checkout session with the API key that owns
|
||||
// the account (Path A / env key), instead of an email session.
|
||||
type TopUpByKeyRequest struct {
|
||||
SessionToken string
|
||||
AmountUSDMinor int
|
||||
PaymentSessionID string // idempotency: same id → same checkout, never a second charge
|
||||
AutoTopUp bool // enroll the account in automatic top-up at checkout (card, saved off-session)
|
||||
SuccessURL string
|
||||
CancelURL string
|
||||
}
|
||||
|
||||
// TopUpByKeyResult is the hosted checkout returned by TopUpByKey.
|
||||
type TopUpByKeyResult struct {
|
||||
Checkout PaymentSession `json:"checkout"`
|
||||
}
|
||||
|
||||
// APIError is a non-2xx HTTP response from the aimlapi.com API. Status carries the
|
||||
// code so callers can branch (e.g. 401 = bad key, 5xx = retryable).
|
||||
type APIError struct {
|
||||
Message string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
// Error renders the method, endpoint, status, and (when present) the response body.
|
||||
func (e APIError) Error() string {
|
||||
if strings.TrimSpace(e.Body) != "" {
|
||||
return fmt.Sprintf("%s: HTTP %d: %s", e.Message, e.Status, strings.TrimSpace(e.Body))
|
||||
}
|
||||
return fmt.Sprintf("%s: HTTP %d", e.Message, e.Status)
|
||||
}
|
||||
|
||||
// Client talks to the aimlapi.com auth, app, and inference APIs for the onboarding
|
||||
// and partner-checkout top-up flows.
|
||||
type Client struct {
|
||||
endpoints Endpoints
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(endpoints Endpoints, httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
return &Client{endpoints: endpoints, httpClient: httpClient}
|
||||
}
|
||||
|
||||
// CreateSession opens a partner-checkout session attributed to partnerID; the
|
||||
// returned SessionToken addresses it on the pay/exchange/poll calls.
|
||||
func (c *Client) CreateSession(ctx context.Context, partnerID string, partnerName string, returnURL string) (PartnerCheckoutSession, error) {
|
||||
body := map[string]any{"partnerId": partnerID}
|
||||
if strings.TrimSpace(partnerName) != "" {
|
||||
body["partnerName"] = strings.TrimSpace(partnerName)
|
||||
}
|
||||
if strings.TrimSpace(returnURL) != "" {
|
||||
body["returnUrl"] = strings.TrimSpace(returnURL)
|
||||
}
|
||||
var result PartnerCheckoutSession
|
||||
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions", "", body, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// GetSession fetches the current state of a partner-checkout session; used to poll
|
||||
// for payment completion.
|
||||
func (c *Client) GetSession(ctx context.Context, sessionToken string) (PartnerCheckoutSession, error) {
|
||||
var result PartnerCheckoutSession
|
||||
err := c.request(ctx, http.MethodGet, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken), "", nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Pay initiates payment for a session and returns the hosted checkout URL.
|
||||
// autoTopUp enrolls the account in automatic top-up at checkout time.
|
||||
func (c *Client) Pay(ctx context.Context, bearer string, sessionToken string, amountUSDMinor int, paymentSessionID string, method PaymentMethod, successURL string, cancelURL string, autoTopUp bool) (PayResult, error) {
|
||||
body := map[string]any{
|
||||
"amountUsdMinor": amountUSDMinor,
|
||||
"paymentSessionId": paymentSessionID,
|
||||
"method": method,
|
||||
}
|
||||
if strings.TrimSpace(successURL) != "" {
|
||||
body["successUrl"] = strings.TrimSpace(successURL)
|
||||
}
|
||||
if strings.TrimSpace(cancelURL) != "" {
|
||||
body["cancelUrl"] = strings.TrimSpace(cancelURL)
|
||||
}
|
||||
// Only sent when enabled: enrolls the account in automatic top-up. The backend
|
||||
// honours this field at checkout time.
|
||||
if autoTopUp {
|
||||
body["autoTopUp"] = true
|
||||
}
|
||||
var result PayResult
|
||||
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/pay", bearer, body, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Exchange converts a paid session into a freshly minted API key (new-account flow).
|
||||
func (c *Client) Exchange(ctx context.Context, bearer string, sessionToken string) (ExchangeResult, error) {
|
||||
var result ExchangeResult
|
||||
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/exchange", bearer, nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// TopUpByKey funds a partner-checkout session using the raw API key that owns the
|
||||
// account (Path A / env key), returning the hosted checkout. It binds the session
|
||||
// to the key's account server-side, so no email session is needed and no key is
|
||||
// exchanged. req.PaymentSessionID makes a retry idempotent (same id → same
|
||||
// checkout, never a second charge).
|
||||
func (c *Client) TopUpByKey(ctx context.Context, apiKey string, req TopUpByKeyRequest) (TopUpByKeyResult, error) {
|
||||
body := map[string]any{
|
||||
"sessionToken": req.SessionToken,
|
||||
"amountUsdMinor": req.AmountUSDMinor,
|
||||
"paymentSessionId": req.PaymentSessionID,
|
||||
}
|
||||
if req.AutoTopUp {
|
||||
body["autoTopUp"] = true
|
||||
}
|
||||
if strings.TrimSpace(req.SuccessURL) != "" {
|
||||
body["successUrl"] = strings.TrimSpace(req.SuccessURL)
|
||||
}
|
||||
if strings.TrimSpace(req.CancelURL) != "" {
|
||||
body["cancelUrl"] = strings.TrimSpace(req.CancelURL)
|
||||
}
|
||||
var result TopUpByKeyResult
|
||||
err := c.request(ctx, http.MethodPost, c.billingV2URL("/billing/topup"), apiKey, body, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// billingV2URL builds a v2 billing API URL from the inference base. The top-up
|
||||
// endpoint lives next to the balance endpoint on the API gateway, one version up
|
||||
// (".../v1" → ".../v2/billing/..."), so it re-anchors the version while keeping any
|
||||
// host/prefix an override supplied.
|
||||
func (c *Client) billingV2URL(path string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(c.endpoints.InferenceBaseURL), "/")
|
||||
base = strings.TrimSuffix(base, "/v1")
|
||||
return strings.TrimRight(base, "/") + "/v2" + path
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, method string, endpoint string, bearer string, body any, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if strings.TrimSpace(bearer) != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearer))
|
||||
}
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("network request to %s failed: %w", endpoint, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
text, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return APIError{Message: method + " " + endpoint, Status: response.StatusCode, Body: string(text)}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if len(bytes.TrimSpace(text)) == 0 {
|
||||
return APIError{Message: method + " " + endpoint + " returned empty body", Status: response.StatusCode}
|
||||
}
|
||||
if err := json.Unmarshal(text, out); err != nil {
|
||||
return APIError{Message: method + " " + endpoint + " returned non-JSON body", Status: response.StatusCode, Body: string(text)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func urlPathEscape(value string) string {
|
||||
return url.PathEscape(value)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Additional client methods backing the interactive TUI onboarding flow. They
|
||||
// target the endpoints shipped in the aimlapi backend:
|
||||
// - GET {inference}/billing/balance (Path A: validate key + balance)
|
||||
// - PATCH{auth}/v1/auth/account (Path B: does the account exist?)
|
||||
// - POST {auth}/v1/auth/sign-in/code(/verify) (Path B existing: passwordless login)
|
||||
// - POST {auth}/v1/auth/account/passwordless (Path B new: email-only signup)
|
||||
// - POST {app}/v1/keys (mint a key once we hold a session)
|
||||
|
||||
// BalanceResult is the wallet balance returned by GetBalance, with the backend's
|
||||
// low-balance flag and threshold.
|
||||
type BalanceResult struct {
|
||||
Balance float64 `json:"balance"`
|
||||
LowBalance bool `json:"lowBalance"`
|
||||
LowBalanceThreshold float64 `json:"lowBalanceThreshold"`
|
||||
}
|
||||
|
||||
// CheckResult reports whether an email already has an account, driving the
|
||||
// sign-in vs sign-up branch of the onboarding flow.
|
||||
type CheckResult struct {
|
||||
// "sign-in" (account exists) or "sign-up" (no account yet).
|
||||
Action string `json:"action"`
|
||||
Provider *string `json:"provider"`
|
||||
}
|
||||
|
||||
// CreatedKey is an API key minted by CreateKey.
|
||||
type CreatedKey struct {
|
||||
Key string `json:"key"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (c *Client) authURL(path string) string {
|
||||
return strings.TrimRight(c.endpoints.AuthBaseURL, "/") + path
|
||||
}
|
||||
|
||||
func (c *Client) appURL(path string) string {
|
||||
return strings.TrimRight(c.endpoints.AppBaseURL, "/") + path
|
||||
}
|
||||
|
||||
func (c *Client) inferenceURL(path string) string {
|
||||
return strings.TrimRight(c.endpoints.InferenceBaseURL, "/") + path
|
||||
}
|
||||
|
||||
// GetBalance doubles as key validation: a 401 means the key is invalid, a 2xx
|
||||
// returns the wallet balance. Auth is the raw API key itself.
|
||||
func (c *Client) GetBalance(ctx context.Context, apiKey string) (BalanceResult, error) {
|
||||
var result BalanceResult
|
||||
err := c.request(ctx, http.MethodGet, c.inferenceURL("/billing/balance"), apiKey, nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CheckAccount reports whether an email already has an aimlapi.com account
|
||||
// (Action "sign-in") or not ("sign-up").
|
||||
func (c *Client) CheckAccount(ctx context.Context, email string) (CheckResult, error) {
|
||||
var result CheckResult
|
||||
err := c.request(ctx, http.MethodPatch, c.authURL("/v1/auth/account"), "", map[string]any{"email": email}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SendSignInCode emails a 6-digit sign-in code to an existing account.
|
||||
func (c *Client) SendSignInCode(ctx context.Context, email string) error {
|
||||
return c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code"), "", map[string]any{"email": email}, nil)
|
||||
}
|
||||
|
||||
// VerifySignInCode exchanges an emailed code for a session bearer token.
|
||||
func (c *Client) VerifySignInCode(ctx context.Context, email string, code string) (AuthResult, error) {
|
||||
var result AuthResult
|
||||
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code/verify"), "", map[string]any{"email": email, "code": code}, &result)
|
||||
if err == nil && strings.TrimSpace(result.Token) == "" {
|
||||
err = fmt.Errorf("aimlapi.com did not return an auth token")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CreatePasswordlessAccount registers a new email-only account and returns its
|
||||
// session bearer token.
|
||||
func (c *Client) CreatePasswordlessAccount(ctx context.Context, email string) (AuthResult, error) {
|
||||
var result AuthResult
|
||||
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/account/passwordless"), "", map[string]any{"email": email}, &result)
|
||||
if err == nil && strings.TrimSpace(result.Token) == "" {
|
||||
err = fmt.Errorf("aimlapi.com did not return an auth token")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CreateKey mints a fresh API key for a session-holding user.
|
||||
func (c *Client) CreateKey(ctx context.Context, bearer string, name string) (CreatedKey, error) {
|
||||
body := map[string]any{}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
body["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
var result CreatedKey
|
||||
err := c.request(ctx, http.MethodPost, c.appURL("/v1/keys"), bearer, body, &result)
|
||||
if err == nil && strings.TrimSpace(result.Key) == "" {
|
||||
err = fmt.Errorf("aimlapi.com did not return an API key")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateKeySendsAuthenticatedRequestAndDecodesKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/keys" {
|
||||
t.Fatalf("request = %s %s, want POST /v1/keys", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer session-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["name"] != "Zero CLI" {
|
||||
t.Fatalf("name = %q", body["name"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"key":"sk-issued","id":"key-id"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
||||
key, err := client.CreateKey(context.Background(), " session-token ", " Zero CLI ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key.Key != "sk-issued" || key.ID != "key-id" {
|
||||
t.Fatalf("key = %#v", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateKeyRejectsEmptyKeyResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"key-id"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
||||
if _, err := client.CreateKey(context.Background(), "session-token", "Zero"); err == nil {
|
||||
t.Fatal("CreateKey accepted a response without an API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodsRejectEmptyTokenResponses(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"token":""}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
|
||||
tests := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{
|
||||
name: "verify sign-in code",
|
||||
call: func() error {
|
||||
_, err := client.VerifySignInCode(context.Background(), "[email protected]", "123456")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create passwordless account",
|
||||
call: func() error {
|
||||
_, err := client.CreatePasswordlessAccount(context.Background(), "[email protected]")
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := test.call(); err == nil {
|
||||
t.Fatal("accepted a response without an auth token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReturnsTypedAPIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "invalid key", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
|
||||
_, err := client.GetBalance(context.Background(), "bad-key")
|
||||
var apiErr APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error = %T %v, want APIError", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusUnauthorized || apiErr.Body != "invalid key\n" {
|
||||
t.Fatalf("APIError = %#v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedResponseRejectsEmptySuccessBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
|
||||
if _, err := client.GetBalance(context.Background(), "key"); err == nil {
|
||||
t.Fatal("GetBalance accepted an empty successful response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodylessOperationAcceptsEmptySuccessBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
|
||||
if err := client.SendSignInCode(context.Background(), "[email protected]"); err != nil {
|
||||
t.Fatalf("SendSignInCode() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAmountUSD(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "default", want: DefaultAmountUSDMinor},
|
||||
{name: "minimum", value: "20", want: 2000},
|
||||
{name: "cents", value: "25.49", want: 2549},
|
||||
{name: "below minimum", value: "19.99", wantErr: true},
|
||||
{name: "above maximum", value: "10001", wantErr: true},
|
||||
{name: "not a number", value: "twenty", wantErr: true},
|
||||
{name: "nan", value: "NaN", wantErr: true},
|
||||
{name: "infinity", value: "Inf", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ParseAmountUSD(test.value)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("ParseAmountUSD(%q) error = %v", test.value, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("ParseAmountUSD(%q) = %d, want %d", test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Endpoints struct {
|
||||
AuthBaseURL string
|
||||
AppBaseURL string
|
||||
InferenceBaseURL string
|
||||
PayBaseURL string
|
||||
// VerificationBaseURL is the aimlapi web-app frontend base. The browser is sent
|
||||
// back here after the partner checkout completes (see BuildPartnerReturnURL), so
|
||||
// it must point at the same environment's front as the one the CLI is talking to
|
||||
// to land the user there. Empty falls back to DefaultReturnURL.
|
||||
VerificationBaseURL string
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultPartnerID = "part_62yQoGYDq4Yqnrj2R1iGrDNJ"
|
||||
DefaultPartnerName = "Gitlawb"
|
||||
PartnerHeaderName = "X-AIMLAPI-Partner-ID"
|
||||
// DefaultReturnURL is the https fallback the browser is sent to after the
|
||||
// checkout / OAuth consent finishes when no frontend base is known. It must be
|
||||
// a real web page the browser can open — NOT a custom scheme like
|
||||
// zero://aimlapi/complete, which fails with "the scheme does not have a
|
||||
// registered handler" because the CLI registers no OS protocol handler. The
|
||||
// CLI learns of success by polling; this URL is purely the browser's landing.
|
||||
DefaultReturnURL = "https://aimlapi.com/app"
|
||||
DefaultModel = "anthropic/claude-sonnet-5"
|
||||
|
||||
MinAmountUSDMinor = 2000
|
||||
MaxAmountUSDMinor = 1000000
|
||||
DefaultAmountUSDMinor = 2500
|
||||
)
|
||||
|
||||
func ResolveEndpoints() Endpoints {
|
||||
return Endpoints{
|
||||
AuthBaseURL: envOrDefault("AIMLAPI_AUTH_URL", "https://auth.aimlapi.com"),
|
||||
AppBaseURL: envOrDefault("AIMLAPI_APP_URL", "https://app.aimlapi.com"),
|
||||
InferenceBaseURL: envOrDefault("AIMLAPI_INFERENCE_URL", "https://api.aimlapi.com/v1"),
|
||||
PayBaseURL: envOrDefault("AIMLAPI_PAY_URL", "https://pay.aimlapi.com"),
|
||||
VerificationBaseURL: envOrDefault("AIMLAPI_VERIFICATION_BASE_URL", "https://aimlapi.com/app"),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvePartnerID applies the same explicit-option, environment, and default
|
||||
// precedence everywhere partner attribution is used. Keeping checkout creation
|
||||
// and saved inference profiles on one resolver prevents them from attributing
|
||||
// the same user to different partners.
|
||||
func ResolvePartnerID(explicit string) string {
|
||||
if value := strings.TrimSpace(explicit); value != "" {
|
||||
return value
|
||||
}
|
||||
return envOrDefault("AIMLAPI_PARTNER_ID", DefaultPartnerID)
|
||||
}
|
||||
|
||||
// WithResolvedPartnerHeader returns a copy with the effective partner ID set.
|
||||
// Header matching is case-insensitive so an existing spelling is replaced rather
|
||||
// than duplicated. Callers remain responsible for applying catalog attribution
|
||||
// only to the canonical AIMLAPI endpoint.
|
||||
func WithResolvedPartnerHeader(headers map[string]string) map[string]string {
|
||||
resolved := make(map[string]string, len(headers)+1)
|
||||
for key, value := range headers {
|
||||
if strings.EqualFold(strings.TrimSpace(key), PartnerHeaderName) {
|
||||
continue
|
||||
}
|
||||
resolved[key] = value
|
||||
}
|
||||
resolved[PartnerHeaderName] = ResolvePartnerID("")
|
||||
return resolved
|
||||
}
|
||||
|
||||
func BuildPartnerCheckoutReturnURLs(appBaseURL string, sessionToken string) (successURL string, cancelURL string) {
|
||||
base := strings.TrimRight(strings.TrimSpace(appBaseURL), "/")
|
||||
if base == "" {
|
||||
// With no base there is nothing to anchor an absolute URL to. Return empty
|
||||
// so the caller omits the return URLs entirely (client.Pay skips empty ones,
|
||||
// leaving the backend's generic checkout) instead of emitting a broken
|
||||
// relative "/checkout?...".
|
||||
return "", ""
|
||||
}
|
||||
token := url.QueryEscape(sessionToken)
|
||||
return base + "/checkout?checkout=success&partnerCheckout=1&sessionToken=" + token,
|
||||
base + "/checkout?checkout=cancel&partnerCheckout=1&sessionToken=" + token
|
||||
}
|
||||
|
||||
// BuildPartnerReturnURL returns the https page the browser is sent back to once
|
||||
// the partner checkout or OAuth consent completes — the aimlapi web app the user
|
||||
// opened the flow from. frontendBaseURL is normally endpoints.VerificationBaseURL
|
||||
// (the front that hosts the login/consent page), so the return follows the same
|
||||
// environment. AIMLAPI_RETURN_URL overrides it outright; an empty base falls back
|
||||
// to DefaultReturnURL. It is deliberately NOT a custom scheme (zero://…): no
|
||||
// browser can hand a custom scheme off without an OS-registered handler, which a
|
||||
// CLI does not install.
|
||||
func BuildPartnerReturnURL(frontendBaseURL string) string {
|
||||
if override := strings.TrimSpace(os.Getenv("AIMLAPI_RETURN_URL")); override != "" {
|
||||
return override
|
||||
}
|
||||
if base := strings.TrimRight(strings.TrimSpace(frontendBaseURL), "/"); base != "" {
|
||||
return base
|
||||
}
|
||||
return DefaultReturnURL
|
||||
}
|
||||
|
||||
func envOrDefault(name string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package aimlapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveEndpointsDefaults(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_AUTH_URL", "")
|
||||
t.Setenv("AIMLAPI_APP_URL", "")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
t.Setenv("AIMLAPI_PAY_URL", "")
|
||||
|
||||
endpoints := ResolveEndpoints()
|
||||
if endpoints.AuthBaseURL != "https://auth.aimlapi.com" ||
|
||||
endpoints.AppBaseURL != "https://app.aimlapi.com" ||
|
||||
endpoints.InferenceBaseURL != "https://api.aimlapi.com/v1" ||
|
||||
endpoints.PayBaseURL != "https://pay.aimlapi.com" {
|
||||
t.Fatalf("unexpected endpoints: %#v", endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPartnerHeaderUsesEnvironmentOverrideWithoutDuplicates(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
|
||||
|
||||
headers := WithResolvedPartnerHeader(map[string]string{
|
||||
"x-aimlapi-partner-id": "part_catalog",
|
||||
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
|
||||
})
|
||||
|
||||
if got := headers[PartnerHeaderName]; got != "part_override" {
|
||||
t.Fatalf("partner header = %q, want part_override", got)
|
||||
}
|
||||
if _, ok := headers["x-aimlapi-partner-id"]; ok {
|
||||
t.Fatalf("case-insensitive partner header was duplicated: %#v", headers)
|
||||
}
|
||||
if got := headers["X-AIMLAPI-Integration-Repo"]; got != "Gitlawb/zero" {
|
||||
t.Fatalf("unrelated header changed: %#v", headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPartnerReturnURL(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_RETURN_URL", "")
|
||||
// The frontend base (the web app the flow was opened from) is returned as-is,
|
||||
// trailing slash trimmed — an https page the browser can actually land on.
|
||||
if got := BuildPartnerReturnURL("https://aimlapi.com/app/"); got != "https://aimlapi.com/app" {
|
||||
t.Fatalf("return URL = %q, want the frontend base", got)
|
||||
}
|
||||
// An empty base falls back to the https default, never a custom scheme.
|
||||
if got := BuildPartnerReturnURL(""); got != DefaultReturnURL {
|
||||
t.Fatalf("empty base return URL = %q, want %q", got, DefaultReturnURL)
|
||||
}
|
||||
|
||||
t.Setenv("AIMLAPI_RETURN_URL", "https://example.test/done")
|
||||
if got := BuildPartnerReturnURL("https://aimlapi.com/app"); got != "https://example.test/done" {
|
||||
t.Fatalf("env override return URL = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package aimlapi
|
||||
|
||||
// UI strings shared by the first-run and provider-wizard onboarding surfaces.
|
||||
|
||||
const (
|
||||
// Path A — existing API key. Pick-path prompt + option labels.
|
||||
MsgAPIKeyInputPrompt = "Enter your aimlapi.com key."
|
||||
MsgAPIKeyInvalid = "API key is invalid. Please make sure you enter a valid aimlapi.com key."
|
||||
MsgPickPathPrompt = "Do you have aimlapi.com key?"
|
||||
MsgPickPathHaveKey = "I already have aimlapi.com key"
|
||||
MsgPickPathHaveHint = "Proceed to paste the key"
|
||||
MsgPickPathNewUser = "I am a new user"
|
||||
MsgPickPathNewHint = "One click set up"
|
||||
// Path B — email.
|
||||
MsgEnterEmail = "Enter your email.\nTo access aimlapi.com dashboard."
|
||||
MsgEmailInvalid = "Email format is incorrect."
|
||||
MsgAccountActionInvalid = "aimlapi.com returned an unsupported account action. Please try again."
|
||||
// %s = email
|
||||
MsgCodeSent = "We sent a 6-digit code to %s.\nEnter it below to continue."
|
||||
MsgCodeIncorrect = "Code you've entered is incorrect."
|
||||
|
||||
// Balance check.
|
||||
MsgLowBalance = "Your aimlapi.com balance is running low.\nIt is recommended to top up your balance."
|
||||
MsgEverythingRuns = "Everything is ready."
|
||||
// Low-balance choices (spec wording).
|
||||
MsgLowBalanceTopUp = "Sure, let's do that"
|
||||
MsgLowBalanceSkip = "I'll skip topping up the balance for now"
|
||||
|
||||
// Top-up workflow.
|
||||
MsgTopUpPrompt = "Add credits.\nEnter an amount (min $20)."
|
||||
MsgAmountRequired = "Please enter a top-up amount."
|
||||
// %s = checkout URL
|
||||
MsgTopUpBrowserFallback = "If the browser did not open automatically please use this link to top up your account: %s"
|
||||
MsgTopUpFailed = "Top up failed. Please try again."
|
||||
MsgTopUpSuccess = "Top-up successful."
|
||||
|
||||
// Success — key delivered.
|
||||
// %s = amount in dollars (e.g. "25")
|
||||
MsgTopUpAddedFmt = "$%s has been added to your balance."
|
||||
// %s = email
|
||||
MsgSuccessMagicLink = "We've emailed you a magic link to %s. Use it to access your aimlapi.com account and review your usage and balance."
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package aimlapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidEmailRejectsIncompleteDomains(t *testing.T) {
|
||||
tests := []struct {
|
||||
value string
|
||||
valid bool
|
||||
}{
|
||||
{value: "[email protected]", valid: true},
|
||||
{value: "[email protected]", valid: true},
|
||||
{value: "123@123.", valid: false},
|
||||
{value: "123@123", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.value, func(t *testing.T) {
|
||||
if got := ValidEmail(test.value); got != test.valid {
|
||||
t.Fatalf("ValidEmail(%q) = %v, want %v", test.value, got, test.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StreamTopUpOptions drives a session-based partner-checkout top-up for the
|
||||
// interactive (TUI) onboarding. The caller already holds a user session —
|
||||
// obtained from an email-code sign-in (Path B existing) or a
|
||||
// passwordless new account (Path B sign-up) — so this skips authentication and
|
||||
// runs create-session → pay → wait-for-payment, optionally exchanging the paid
|
||||
// session into a fresh key (new accounts) versus keeping the caller's key.
|
||||
type StreamTopUpOptions struct {
|
||||
SessionToken string // user session bearer (required)
|
||||
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
|
||||
// InferenceBaseURL pins key-bound billing and the returned provider profile to
|
||||
// the endpoint that validated the credential. Empty uses ResolveEndpoints.
|
||||
InferenceBaseURL string
|
||||
Method PaymentMethod
|
||||
AutoTopUp bool // enroll the account in automatic top-up at checkout time
|
||||
Exchange bool // exchange the paid session into a new key (new accounts)
|
||||
Model string
|
||||
PartnerID string
|
||||
PartnerName string
|
||||
NoOpen bool
|
||||
|
||||
// ResumeSessionToken, when set, resumes that partner-checkout session instead
|
||||
// of opening a new one, so a retry after an ambiguous failure (a lost Pay/
|
||||
// Exchange response) can never double-charge or strand an already-paid session.
|
||||
ResumeSessionToken string
|
||||
// PaymentSessionID is generated once per amount/auto-top-up intent and reused
|
||||
// on retry so an ambiguous Pay response cannot create a second checkout.
|
||||
PaymentSessionID string
|
||||
|
||||
HTTPClient *http.Client
|
||||
OpenBrowser func(string) error
|
||||
OnStatus func(Status, string)
|
||||
// OnSession reports the partner-checkout session token the moment it exists (a
|
||||
// fresh CreateSession, or the resumed one), so the caller can retain it and
|
||||
// resume on a later failure. It fires with "" when the session is dead and the
|
||||
// retained token should be dropped (a fresh run must start over).
|
||||
OnSession func(sessionToken string)
|
||||
}
|
||||
|
||||
// topupPhase is where StreamTopUp enters its create-checkout → poll → exchange
|
||||
// pipeline. A fresh run starts at phasePay; a resume skips ahead to whatever the
|
||||
// existing session's status makes safe.
|
||||
type topupPhase int
|
||||
|
||||
const (
|
||||
phasePay topupPhase = iota // (re-)open the Stripe checkout, then poll + exchange
|
||||
phasePoll // checkout already open; poll for payment, never re-pay
|
||||
phaseExchange // already paid; exchange only (recovers a lost key)
|
||||
phaseWaitExchange // an exchange claim is in flight; poll, never call Exchange twice
|
||||
)
|
||||
|
||||
// StreamTopUp performs the browser-checkout top-up and reports progress via
|
||||
// OnStatus.
|
||||
func StreamTopUp(ctx context.Context, options StreamTopUpOptions) (ProvisionedKey, error) {
|
||||
endpoints := ResolveEndpoints()
|
||||
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
|
||||
endpoints.InferenceBaseURL = baseURL
|
||||
}
|
||||
client := NewClient(endpoints, options.HTTPClient)
|
||||
partnerID := ResolvePartnerID(options.PartnerID)
|
||||
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
|
||||
method := options.Method
|
||||
if method != PaymentMethodCrypto {
|
||||
method = PaymentMethodCard
|
||||
}
|
||||
amount, err := ParseAmountUSD(options.AmountUSD)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(options.SessionToken) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("a session is required to top up")
|
||||
}
|
||||
if strings.TrimSpace(options.PaymentSessionID) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
|
||||
}
|
||||
|
||||
status(options.OnStatus, StatusCreatingSession, "")
|
||||
sessionToken, phase, err := resolveTopupSession(ctx, client, options, partnerID, partnerName, endpoints)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
// Retain the live token so an ambiguous failure below can resume this session
|
||||
// rather than open a second checkout on the next attempt.
|
||||
reportSession(options.OnSession, sessionToken)
|
||||
|
||||
if phase <= phasePay {
|
||||
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
|
||||
status(options.OnStatus, StatusOpeningCheckout, "")
|
||||
pay, err := client.Pay(ctx, options.SessionToken, sessionToken, amount, options.PaymentSessionID, method, successURL, cancelURL, options.AutoTopUp)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
|
||||
}
|
||||
|
||||
paidToken := sessionToken
|
||||
if phase <= phasePoll {
|
||||
status(options.OnStatus, StatusWaitingPayment, "")
|
||||
paid, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
paidToken = paid.SessionToken
|
||||
if paid.Status == SessionStatusExchanging {
|
||||
// Another/lost request claimed exchange while we were polling payment.
|
||||
// Carry that fact into the provisioning branch so it follows the claim
|
||||
// instead of issuing a second single-flight Exchange request.
|
||||
phase = phaseWaitExchange
|
||||
}
|
||||
}
|
||||
|
||||
result := ProvisionedKey{
|
||||
BaseURL: endpoints.InferenceBaseURL,
|
||||
Model: firstNonEmpty(options.Model, DefaultModel),
|
||||
}
|
||||
if options.Exchange {
|
||||
status(options.OnStatus, StatusProvisioningKey, "")
|
||||
if phase == phaseWaitExchange {
|
||||
return ProvisionedKey{}, pollUntilExchangeSettled(ctx, client, sessionToken, options.OnSession)
|
||||
}
|
||||
exchange, err := client.Exchange(ctx, options.SessionToken, paidToken)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(exchange.APIKey) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("aimlapi.com did not return an API key")
|
||||
}
|
||||
result.APIKey = exchange.APIKey
|
||||
result.APIKeyID = exchange.APIKeyID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveTopupSession returns the partner-checkout session token to drive and the
|
||||
// phase to enter. A fresh run opens a new session at phasePay. A resume inspects
|
||||
// the existing session and enters the earliest phase that cannot re-charge:
|
||||
// - pending_auth → phasePay: payment never started, so (re-)Pay is safe.
|
||||
// - pending_payment → phasePoll: a checkout is already open; poll, never re-Pay.
|
||||
// - paid → phaseExchange: exchange the paid session once.
|
||||
// - exchanging → phaseWaitExchange: another/lost request owns the atomic
|
||||
// exchange claim; poll it, never issue a second Exchange request.
|
||||
// - exchanged → terminal: the one-shot raw key is gone; point at dashboard
|
||||
// recovery and keep the token so a re-run repeats that guidance, not a charge.
|
||||
// - cancelled/expired/failed → terminal: drop the dead token so a re-run is fresh.
|
||||
func resolveTopupSession(ctx context.Context, client *Client, options StreamTopUpOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
|
||||
resume := strings.TrimSpace(options.ResumeSessionToken)
|
||||
if resume == "" {
|
||||
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
|
||||
if err != nil {
|
||||
return "", phasePay, err
|
||||
}
|
||||
return session.SessionToken, phasePay, nil
|
||||
}
|
||||
session, err := client.GetSession(ctx, resume)
|
||||
if err != nil {
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(options.OnSession, "")
|
||||
}
|
||||
return "", phasePay, err
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusPendingAuth:
|
||||
return resume, phasePay, nil
|
||||
case SessionStatusPendingPayment:
|
||||
return resume, phasePoll, nil
|
||||
case SessionStatusPaid:
|
||||
return resume, phaseExchange, nil
|
||||
case SessionStatusExchanging:
|
||||
return resume, phaseWaitExchange, nil
|
||||
case SessionStatusExchanged:
|
||||
return "", phasePay, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
||||
default:
|
||||
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
|
||||
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func reportSession(callback func(string), sessionToken string) {
|
||||
if callback != nil {
|
||||
callback(sessionToken)
|
||||
}
|
||||
}
|
||||
|
||||
// announceCheckout opens the hosted checkout in the browser (unless suppressed)
|
||||
// and reports the URL either way, so the TUI can always show the direct link.
|
||||
func announceCheckout(onStatus func(Status, string), open func(string) error, noOpen bool, checkoutURL string) {
|
||||
if noOpen || open == nil {
|
||||
status(onStatus, StatusOpeningCheckout, checkoutURL)
|
||||
return
|
||||
}
|
||||
if err := open(checkoutURL); err != nil {
|
||||
status(onStatus, StatusOpeningCheckout, "Open manually: "+checkoutURL)
|
||||
return
|
||||
}
|
||||
status(onStatus, StatusOpeningCheckout, checkoutURL)
|
||||
}
|
||||
|
||||
func validatedCheckoutURL(value string) (string, error) {
|
||||
checkoutURL := strings.TrimSpace(value)
|
||||
parsed, err := url.Parse(checkoutURL)
|
||||
if err != nil || !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "https") || parsed.Hostname() == "" || parsed.User != nil {
|
||||
return "", fmt.Errorf("payment provider did not return a valid HTTPS checkout URL")
|
||||
}
|
||||
return checkoutURL, nil
|
||||
}
|
||||
|
||||
// StreamTopUpByKeyOptions drives a top-up funded with the API key that already
|
||||
// owns the account (Path A pasted key, or AIMLAPI_API_KEY). Unlike StreamTopUp it
|
||||
// needs no email session and never exchanges a key — the caller already holds one,
|
||||
// so it only funds the wallet: create-session → pay-by-key → wait-for-payment.
|
||||
type StreamTopUpByKeyOptions struct {
|
||||
APIKey string // raw key that owns the account (required)
|
||||
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
|
||||
InferenceBaseURL string // validated key endpoint; empty uses ResolveEndpoints
|
||||
AutoTopUp bool // enroll the account in automatic top-up at checkout time
|
||||
PartnerID string
|
||||
PartnerName string
|
||||
NoOpen bool
|
||||
|
||||
// ResumeSessionToken resumes an existing partner-checkout session instead of
|
||||
// opening a new one. PaymentSessionID is the idempotency handle carried across
|
||||
// retries so a re-issued pay returns the same checkout, never a second charge.
|
||||
ResumeSessionToken string
|
||||
PaymentSessionID string // required; generate once via NewPaymentSessionID, reuse on retry
|
||||
|
||||
HTTPClient *http.Client
|
||||
OpenBrowser func(string) error
|
||||
OnStatus func(Status, string)
|
||||
OnSession func(sessionToken string)
|
||||
}
|
||||
|
||||
// StreamTopUpByKey funds the account behind APIKey via the hosted checkout and
|
||||
// reports progress through OnStatus. It returns an empty ProvisionedKey: the
|
||||
// pasted/env key is unchanged, only the balance grows.
|
||||
func StreamTopUpByKey(ctx context.Context, options StreamTopUpByKeyOptions) (ProvisionedKey, error) {
|
||||
endpoints := ResolveEndpoints()
|
||||
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
|
||||
endpoints.InferenceBaseURL = baseURL
|
||||
}
|
||||
client := NewClient(endpoints, options.HTTPClient)
|
||||
partnerID := ResolvePartnerID(options.PartnerID)
|
||||
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
|
||||
amount, err := ParseAmountUSD(options.AmountUSD)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(options.APIKey) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("an API key is required to top up")
|
||||
}
|
||||
if strings.TrimSpace(options.PaymentSessionID) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
|
||||
}
|
||||
|
||||
status(options.OnStatus, StatusCreatingSession, "")
|
||||
sessionToken, phase, err := resolveByKeySession(ctx, client, options, partnerID, partnerName, endpoints)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
reportSession(options.OnSession, sessionToken)
|
||||
|
||||
if phase <= phasePay {
|
||||
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
|
||||
status(options.OnStatus, StatusOpeningCheckout, "")
|
||||
pay, err := client.TopUpByKey(ctx, options.APIKey, TopUpByKeyRequest{
|
||||
SessionToken: sessionToken,
|
||||
AmountUSDMinor: amount,
|
||||
PaymentSessionID: options.PaymentSessionID,
|
||||
AutoTopUp: options.AutoTopUp,
|
||||
SuccessURL: successURL,
|
||||
CancelURL: cancelURL,
|
||||
})
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
|
||||
}
|
||||
|
||||
if phase <= phasePoll {
|
||||
status(options.OnStatus, StatusWaitingPayment, "")
|
||||
if _, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession); err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
}
|
||||
// No exchange: the account is funded and the caller keeps its existing key.
|
||||
return ProvisionedKey{}, nil
|
||||
}
|
||||
|
||||
// resolveByKeySession mirrors resolveTopupSession for the by-key flow. Because
|
||||
// by-key never exchanges, a paid/exchanging/exchanged session all mean "already
|
||||
// funded, done" (phaseExchange skips straight past pay + poll to the empty result).
|
||||
func resolveByKeySession(ctx context.Context, client *Client, options StreamTopUpByKeyOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
|
||||
resume := strings.TrimSpace(options.ResumeSessionToken)
|
||||
if resume == "" {
|
||||
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
|
||||
if err != nil {
|
||||
return "", phasePay, err
|
||||
}
|
||||
return session.SessionToken, phasePay, nil
|
||||
}
|
||||
session, err := client.GetSession(ctx, resume)
|
||||
if err != nil {
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(options.OnSession, "")
|
||||
}
|
||||
return "", phasePay, err
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusPendingAuth:
|
||||
return resume, phasePay, nil
|
||||
case SessionStatusPendingPayment:
|
||||
return resume, phasePoll, nil
|
||||
case SessionStatusPaid, SessionStatusExchanging, SessionStatusExchanged:
|
||||
return resume, phaseExchange, nil
|
||||
default:
|
||||
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
|
||||
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStreamTopUpByKeyFundsWithoutExchange(t *testing.T) {
|
||||
var topupCalls, exchangeCalls int
|
||||
var topupBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
|
||||
topupCalls++
|
||||
_ = json.NewDecoder(r.Body).Decode(&topupBody)
|
||||
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
|
||||
exchangeCalls++
|
||||
t.Errorf("by-key must never exchange a key")
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", server.URL+"/v1")
|
||||
|
||||
var opened string
|
||||
result, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
|
||||
APIKey: "sk-user",
|
||||
AmountUSD: "20",
|
||||
AutoTopUp: true,
|
||||
PaymentSessionID: "pay-1",
|
||||
OpenBrowser: func(u string) error { opened = u; return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUpByKey = %v", err)
|
||||
}
|
||||
if result.APIKey != "" {
|
||||
t.Fatalf("by-key must not mint a key, got %q", result.APIKey)
|
||||
}
|
||||
if topupCalls != 1 || exchangeCalls != 0 {
|
||||
t.Fatalf("topup=%d exchange=%d, want 1/0", topupCalls, exchangeCalls)
|
||||
}
|
||||
if opened != "https://pay/checkout" {
|
||||
t.Fatalf("opened checkout = %q", opened)
|
||||
}
|
||||
if topupBody["autoTopUp"] != true || topupBody["paymentSessionId"] != "pay-1" {
|
||||
t.Fatalf("topup body missing autoTopUp/paymentSessionId: %#v", topupBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedCheckoutURLRequiresAbsoluteHTTPS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "https", value: " https://pay.example.test/checkout?id=1 ", want: "https://pay.example.test/checkout?id=1"},
|
||||
{name: "empty", wantErr: true},
|
||||
{name: "relative", value: "/checkout", wantErr: true},
|
||||
{name: "http", value: "http://pay.example.test/checkout", wantErr: true},
|
||||
{name: "file", value: "file:///tmp/checkout", wantErr: true},
|
||||
{name: "custom protocol", value: "wallet://checkout/123", wantErr: true},
|
||||
{name: "userinfo", value: "https://[email protected]/checkout", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := validatedCheckoutURL(test.value)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("validatedCheckoutURL(%q) error = %v", test.value, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("validatedCheckoutURL(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpByKeyPinsValidatedInferenceEndpoint(t *testing.T) {
|
||||
validatedCalls := 0
|
||||
validated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
|
||||
validatedCalls++
|
||||
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
|
||||
default:
|
||||
t.Errorf("unexpected validated-endpoint request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer validated.Close()
|
||||
override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("validated key leaked to process override: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer override.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", validated.URL)
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", override.URL)
|
||||
|
||||
_, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
|
||||
APIKey: "production-key",
|
||||
AmountUSD: "20",
|
||||
InferenceBaseURL: validated.URL,
|
||||
PaymentSessionID: "payment-1",
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUpByKey() error = %v", err)
|
||||
}
|
||||
if validatedCalls != 1 {
|
||||
t.Fatalf("validated endpoint top-up calls = %d, want 1", validatedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpEmailRetrySendsStablePaymentSessionID(t *testing.T) {
|
||||
getCalls := 0
|
||||
var paymentSessionID string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
|
||||
status := SessionStatusPendingAuth
|
||||
if getCalls > 0 {
|
||||
status = SessionStatusPaid
|
||||
}
|
||||
getCalls++
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_retry", Status: status})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
paymentSessionID, _ = body["paymentSessionId"].(string)
|
||||
_ = json.NewEncoder(w).Encode(PayResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pcs_retry",
|
||||
PaymentSessionID: "payment-stable",
|
||||
AmountUSD: "20",
|
||||
NoOpen: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUp() error = %v", err)
|
||||
}
|
||||
if paymentSessionID != "payment-stable" {
|
||||
t.Fatalf("paymentSessionId = %q, want stable retry id", paymentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// resumeServer is a partner-checkout backend fake that fails the test if the
|
||||
// re-charging endpoints (create-session / pay) are ever hit, and serves a
|
||||
// scripted GetSession status sequence so a resume can be driven deterministically.
|
||||
type resumeServer struct {
|
||||
t *testing.T
|
||||
statuses []string // GetSession status per call, last value repeats
|
||||
getCalls int
|
||||
payCalls int
|
||||
createCall int
|
||||
exchanged int
|
||||
}
|
||||
|
||||
func (rs *resumeServer) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
|
||||
rs.createCall++
|
||||
rs.t.Errorf("resume must not create a new session")
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
|
||||
rs.payCalls++
|
||||
rs.t.Errorf("resume must not re-pay (double charge)")
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
|
||||
rs.exchanged++
|
||||
_ = json.NewEncoder(w).Encode(ExchangeResult{APIKey: "sk-resumed", APIKeyID: "key_1"})
|
||||
case r.Method == http.MethodGet:
|
||||
status := rs.statuses[len(rs.statuses)-1]
|
||||
if rs.getCalls < len(rs.statuses) {
|
||||
status = rs.statuses[rs.getCalls]
|
||||
}
|
||||
rs.getCalls++
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pc_tok", Status: SessionStatus(status)})
|
||||
default:
|
||||
rs.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumePaidExchangesWithoutRecharge(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"paid"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
var seen string
|
||||
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
OnSession: func(token string) { seen = token },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUp resume = %v", err)
|
||||
}
|
||||
if result.APIKey != "sk-resumed" {
|
||||
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
|
||||
}
|
||||
if rs.exchanged != 1 {
|
||||
t.Fatalf("exchange calls = %d, want 1", rs.exchanged)
|
||||
}
|
||||
if seen != "pc_tok" {
|
||||
t.Fatalf("OnSession token = %q, want retained pc_tok", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumePendingPaymentPollsNeverRepays(t *testing.T) {
|
||||
// First GetSession (resume resolution) sees the still-open checkout; the next
|
||||
// (poll) sees it settle. Pay must never be called — the fake fails the test if
|
||||
// it is.
|
||||
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "paid"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUp resume = %v", err)
|
||||
}
|
||||
if result.APIKey != "sk-resumed" {
|
||||
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
|
||||
}
|
||||
if rs.payCalls != 0 {
|
||||
t.Fatalf("pay calls = %d, want 0", rs.payCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpPollingDeadSessionClearsRetainedToken(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "expired"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
seen := []string{}
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
OnSession: func(token string) { seen = append(seen, token) },
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "payment expired") {
|
||||
t.Fatalf("err = %v, want expired payment error", err)
|
||||
}
|
||||
if len(seen) == 0 || seen[len(seen)-1] != "" {
|
||||
t.Fatalf("OnSession events = %#v, want terminal clear", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollUntilPaidTerminal4xxClearsRetainedToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "session rejected", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
seen := "retained"
|
||||
_, err := pollUntilPaid(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pc_missing", func(token string) {
|
||||
seen = token
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("pollUntilPaid() error = nil, want terminal 4xx")
|
||||
}
|
||||
if seen != "" {
|
||||
t.Fatalf("retained session = %q, want cleared", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollUntilExchangeSettledClearsDeadSession(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
statusCode int
|
||||
status SessionStatus
|
||||
}{
|
||||
{name: "terminal API rejection", statusCode: http.StatusNotFound},
|
||||
{name: "expired exchange claim", status: SessionStatusExpired},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if test.statusCode != 0 {
|
||||
http.Error(w, "session rejected", test.statusCode)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_dead", Status: test.status})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
seen := "retained"
|
||||
err := pollUntilExchangeSettled(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pcs_dead", func(token string) {
|
||||
seen = token
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("pollUntilExchangeSettled() error = nil")
|
||||
}
|
||||
if seen != "" {
|
||||
t.Fatalf("retained session = %q, want cleared", seen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeHelpersClearOnlyRejectedSessions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantClear bool
|
||||
}{
|
||||
{name: "terminal 4xx", status: http.StatusNotFound, wantClear: true},
|
||||
{name: "retryable 5xx", status: http.StatusServiceUnavailable, wantClear: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "session lookup failed", test.status)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
||||
|
||||
for _, flow := range []struct {
|
||||
name string
|
||||
resolve func(func(string)) error
|
||||
}{
|
||||
{
|
||||
name: "session bearer",
|
||||
resolve: func(onSession func(string)) error {
|
||||
_, _, err := resolveTopupSession(context.Background(), client, StreamTopUpOptions{
|
||||
ResumeSessionToken: "pc_rejected", OnSession: onSession,
|
||||
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "api key",
|
||||
resolve: func(onSession func(string)) error {
|
||||
_, _, err := resolveByKeySession(context.Background(), client, StreamTopUpByKeyOptions{
|
||||
ResumeSessionToken: "pc_rejected", OnSession: onSession,
|
||||
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
|
||||
return err
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(flow.name, func(t *testing.T) {
|
||||
seen := "retained"
|
||||
if err := flow.resolve(func(token string) { seen = token }); err == nil {
|
||||
t.Fatal("resolve error = nil")
|
||||
}
|
||||
if cleared := seen == ""; cleared != test.wantClear {
|
||||
t.Fatalf("session cleared = %v, want %v", cleared, test.wantClear)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumeExchangedReturnsRecoveryError(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"exchanged"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
|
||||
t.Fatalf("err = %v, want an already-exchanged recovery error", err)
|
||||
}
|
||||
if rs.exchanged != 0 {
|
||||
t.Fatalf("exchange calls = %d, want 0 (no second key)", rs.exchanged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumeExchangingPollsWithoutSecondExchange(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"exchanging", "exchanged"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
|
||||
t.Fatalf("err = %v, want dashboard recovery guidance", err)
|
||||
}
|
||||
if rs.exchanged != 0 {
|
||||
t.Fatalf("exchange calls = %d, want 0 while an exchange claim is in flight", rs.exchanged)
|
||||
}
|
||||
if rs.createCall != 0 || rs.payCalls != 0 {
|
||||
t.Fatalf("resume created/paid a new checkout: create=%d pay=%d", rs.createCall, rs.payCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpPollObservesExchangingWithoutSecondExchange(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "exchanging", "exchanged"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
|
||||
t.Fatalf("err = %v, want dashboard recovery guidance", err)
|
||||
}
|
||||
if rs.exchanged != 0 {
|
||||
t.Fatalf("exchange calls = %d, want 0 after poll observed an existing claim", rs.exchanged)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status is a progress phase reported by StreamTopUp through its OnStatus callback.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusCreatingSession Status = "creating-session"
|
||||
StatusOpeningCheckout Status = "opening-checkout"
|
||||
StatusWaitingPayment Status = "waiting-payment"
|
||||
StatusProvisioningKey Status = "provisioning-key"
|
||||
)
|
||||
|
||||
// ProvisionedKey is the outcome of a top-up: the (optionally minted) API key plus
|
||||
// the inference base URL and model to write into the provider profile.
|
||||
type ProvisionedKey struct {
|
||||
APIKey string
|
||||
APIKeyID string
|
||||
BaseURL string
|
||||
Model string
|
||||
}
|
||||
|
||||
const (
|
||||
pollInterval = 3 * time.Second
|
||||
pollTimeout = 20 * time.Minute
|
||||
)
|
||||
|
||||
// ParseAmountUSD parses a dollar string into USD minor units (cents), enforcing the
|
||||
// min/max top-up bounds. An empty value yields the default amount.
|
||||
func ParseAmountUSD(value string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return DefaultAmountUSDMinor, nil
|
||||
}
|
||||
dollars, err := strconv.ParseFloat(value, 64)
|
||||
// NaN/±Inf parse without error and slip past the min/max comparisons below
|
||||
// (every ordered comparison with NaN is false), so reject them explicitly
|
||||
// before the cents conversion turns them into a garbage minor-unit amount.
|
||||
if err != nil || math.IsNaN(dollars) || math.IsInf(dollars, 0) || dollars <= 0 {
|
||||
return 0, fmt.Errorf("invalid amount %q; pass a positive USD amount", value)
|
||||
}
|
||||
minor := int(dollars*100 + 0.5)
|
||||
if minor < MinAmountUSDMinor {
|
||||
return 0, fmt.Errorf("minimum top-up is $%d", MinAmountUSDMinor/100)
|
||||
}
|
||||
if minor > MaxAmountUSDMinor {
|
||||
return 0, fmt.Errorf("maximum top-up is $%d", MaxAmountUSDMinor/100)
|
||||
}
|
||||
return minor, nil
|
||||
}
|
||||
|
||||
// NewPaymentSessionID returns a random UUIDv4-shaped idempotency id for a top-up.
|
||||
// The caller generates it once per top-up intent and reuses it on retry so the
|
||||
// backend (and Stripe) coalesce retries onto the same checkout instead of a
|
||||
// second charge.
|
||||
func NewPaymentSessionID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
|
||||
func pollUntilPaid(ctx context.Context, client *Client, sessionToken string, onSession func(string)) (PartnerCheckoutSession, error) {
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
session, err := client.GetSession(ctx, sessionToken)
|
||||
if err != nil {
|
||||
// Context cancellation/deadline is terminal (also covers an in-flight
|
||||
// request cancelled mid-poll, whose transport error wraps ctx.Err()).
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
// A 4xx API response is terminal. Everything else — a 5xx or a transient
|
||||
// transport error (non-APIError, e.g. a dropped connection during the
|
||||
// wait) — is retried until the poll deadline.
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(onSession, "")
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusPaid, SessionStatusExchanging:
|
||||
return session, nil
|
||||
case SessionStatusExchanged:
|
||||
return PartnerCheckoutSession{}, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
||||
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
|
||||
// The checkout cannot recover from these states. Drop the retained token
|
||||
// (and the TUI's paired by-key idempotency ID) now so the very next retry
|
||||
// creates a fresh payment intent.
|
||||
reportSession(onSession, "")
|
||||
return PartnerCheckoutSession{}, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
||||
default:
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return PartnerCheckoutSession{}, fmt.Errorf("timed out waiting for payment; re-run once the payment clears")
|
||||
}
|
||||
|
||||
func isTerminalSessionAPIError(err error) bool {
|
||||
var apiErr APIError
|
||||
return errors.As(err, &apiErr) && apiErr.Status >= http.StatusBadRequest && apiErr.Status < http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// pollUntilExchangeSettled follows an exchange claim that was already in flight
|
||||
// when a retry resumed the session. Exchange is single-flight and the raw key is
|
||||
// returned only to the winning request, so issuing Exchange again is never safe.
|
||||
// Once the claim completes, direct the user to the dashboard recovery path.
|
||||
func pollUntilExchangeSettled(ctx context.Context, client *Client, sessionToken string, onSession func(string)) error {
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
session, err := client.GetSession(ctx, sessionToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(onSession, "")
|
||||
return err
|
||||
}
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusExchanged:
|
||||
return fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
||||
case SessionStatusExchanging:
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
|
||||
reportSession(onSession, "")
|
||||
return fmt.Errorf("key provisioning %s; rotate the key from the aimlapi.com dashboard", session.Status)
|
||||
default:
|
||||
return fmt.Errorf("key provisioning returned to %s; re-run the top-up", session.Status)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("timed out waiting for key provisioning; retry to check the same session")
|
||||
}
|
||||
|
||||
func sleepContext(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func status(callback func(Status, string), value Status, detail string) {
|
||||
if callback != nil {
|
||||
callback(value, detail)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidEmail performs the lightweight validation used before starting the
|
||||
// passwordless onboarding request.
|
||||
func ValidEmail(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
address, err := mail.ParseAddress(value)
|
||||
if err != nil || address.Address != value {
|
||||
return false
|
||||
}
|
||||
at := strings.LastIndexByte(value, '@')
|
||||
if at <= 0 || at == len(value)-1 {
|
||||
return false
|
||||
}
|
||||
domain := value[at+1:]
|
||||
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") {
|
||||
return false
|
||||
}
|
||||
dot := strings.LastIndexByte(domain, '.')
|
||||
if dot <= 0 || dot == len(domain)-1 {
|
||||
return false
|
||||
}
|
||||
tld := domain[dot+1:]
|
||||
if len(tld) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, char := range tld {
|
||||
if char < 'A' || char > 'Z' && char < 'a' || char > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+6
-5
@@ -766,11 +766,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
|
||||
@@ -348,6 +348,83 @@ func TestRunProvidersAddWritesCatalogProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersAddAimlapiWritesDefaultHeaders(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "add", "aimlapi"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("providers = %#v, want one provider", cfg.Providers)
|
||||
}
|
||||
profile := cfg.Providers[0]
|
||||
if profile.Name != "aimlapi" ||
|
||||
profile.CatalogID != "aimlapi" ||
|
||||
profile.BaseURL != "https://api.aimlapi.com/v1" ||
|
||||
profile.Model != "anthropic/claude-sonnet-5" ||
|
||||
profile.APIKeyEnv != "AIMLAPI_API_KEY" {
|
||||
t.Fatalf("unexpected provider profile: %#v", profile)
|
||||
}
|
||||
if profile.CustomHeaders["X-AIMLAPI-Partner-ID"] != "part_62yQoGYDq4Yqnrj2R1iGrDNJ" ||
|
||||
profile.CustomHeaders["X-AIMLAPI-Integration-Repo"] != "Gitlawb/zero" {
|
||||
t.Fatalf("missing aimlapi.com default headers: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersAddAimlapiMixedCaseHeaderOverride(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
// A differently-cased override must replace the catalog header in place, not
|
||||
// leave both spellings behind to race when request construction canonicalizes.
|
||||
exitCode := runWithDeps([]string{
|
||||
"providers", "add", "aimlapi",
|
||||
"--header", "x-aimlapi-partner-id=part_override",
|
||||
}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
profile := readFileConfig(t, configPath).Providers[0]
|
||||
if _, ok := profile.CustomHeaders["x-aimlapi-partner-id"]; ok {
|
||||
t.Fatalf("lowercase override left a duplicate key: %#v", profile.CustomHeaders)
|
||||
}
|
||||
if profile.CustomHeaders["X-AIMLAPI-Partner-ID"] != "part_override" {
|
||||
t.Fatalf("override did not win on the canonical key: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersAddAimlapiOverrideDropsCatalogHeaders(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
exitCode := runWithDeps([]string{
|
||||
"providers", "add", "aimlapi",
|
||||
"--base-url", "https://staging.example/v1",
|
||||
"--header", "X-Environment=staging",
|
||||
}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
profile := readFileConfig(t, configPath).Providers[0]
|
||||
if _, ok := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; ok {
|
||||
t.Fatalf("partner attribution leaked to overridden endpoint: %#v", profile.CustomHeaders)
|
||||
}
|
||||
if _, ok := profile.CustomHeaders["X-AIMLAPI-Integration-Repo"]; ok {
|
||||
t.Fatalf("integration attribution leaked to overridden endpoint: %#v", profile.CustomHeaders)
|
||||
}
|
||||
if profile.CustomHeaders["X-Environment"] != "staging" {
|
||||
t.Fatalf("explicit user header was dropped: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersAddWritesCustomHeaders(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
+19
-5
@@ -853,14 +853,28 @@ func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig
|
||||
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: backend,
|
||||
Scope: scope,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: backend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
|
||||
keys := make([]string, 0, len(resolved.Providers)+1)
|
||||
for _, profile := range resolved.Providers {
|
||||
if key := strings.TrimSpace(profile.APIKeyEnv); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
if key := strings.TrimSpace(resolved.Provider.APIKeyEnv); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
|
||||
@@ -17,7 +17,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
defaultModel string
|
||||
}{
|
||||
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
|
||||
{id: "aimlapi", name: "AI/ML API", transport: "openai-compatible", defaultModel: "openai/gpt-5-chat"},
|
||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
@@ -48,21 +47,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCatalogAIMLAPIRequiresAPIKey(t *testing.T) {
|
||||
output := runProviderCatalogOnboarding(t)
|
||||
block := providerCatalogOnboardingBlock(t, output, "aimlapi")
|
||||
|
||||
for _, want := range []string{
|
||||
"requiresAuth=true",
|
||||
"authEnvVars=AIMLAPI_API_KEY",
|
||||
"setup: zero providers setup aimlapi --set-active",
|
||||
} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Fatalf("expected AIMLAPI catalog block to contain %q, got:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
|
||||
output := runProviderCatalogOnboarding(t)
|
||||
block := providerCatalogOnboardingBlock(t, output, "longcat")
|
||||
|
||||
@@ -3,8 +3,10 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
@@ -424,22 +426,39 @@ func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile,
|
||||
if apiKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 {
|
||||
apiKeyEnv = descriptor.AuthEnvVars[0]
|
||||
}
|
||||
baseURL := firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL)
|
||||
var catalogHeaders map[string]string
|
||||
if sameProviderSetupBaseURL(baseURL, descriptor.DefaultBaseURL) {
|
||||
catalogHeaders = descriptor.CustomHeaders
|
||||
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
|
||||
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
|
||||
}
|
||||
}
|
||||
profile := config.ProviderProfile{
|
||||
Name: name,
|
||||
ProviderKind: providerKindForDescriptor(descriptor),
|
||||
CatalogID: descriptor.ID,
|
||||
BaseURL: firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL),
|
||||
BaseURL: baseURL,
|
||||
APIKeyEnv: apiKeyEnv,
|
||||
APIFormat: firstAPIFormat(descriptor),
|
||||
AuthHeader: strings.TrimSpace(options.authHeader),
|
||||
AuthScheme: normalizeAuthScheme(options.authScheme),
|
||||
AuthHeaderValue: strings.TrimSpace(options.authHeaderValue),
|
||||
CustomHeaders: copyProviderHeaders(options.customHeaders),
|
||||
Model: firstNonEmptyCLI(options.model, descriptor.DefaultModel),
|
||||
// Catalog attribution belongs only to the catalog endpoint. Explicit
|
||||
// user headers still apply to an override/staging/proxy endpoint.
|
||||
CustomHeaders: mergeProviderHeaders(catalogHeaders, options.customHeaders),
|
||||
Model: firstNonEmptyCLI(options.model, descriptor.DefaultModel),
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func sameProviderSetupBaseURL(left string, right string) bool {
|
||||
return strings.EqualFold(
|
||||
strings.TrimRight(strings.TrimSpace(left), "/"),
|
||||
strings.TrimRight(strings.TrimSpace(right), "/"),
|
||||
)
|
||||
}
|
||||
|
||||
func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config.ProviderProfile, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
@@ -544,3 +563,43 @@ func copyProviderHeaders(headers map[string]string) map[string]string {
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func mergeProviderHeaders(base map[string]string, overrides map[string]string) map[string]string {
|
||||
merged := copyProviderHeaders(base)
|
||||
if len(overrides) == 0 {
|
||||
return merged
|
||||
}
|
||||
if merged == nil {
|
||||
merged = map[string]string{}
|
||||
}
|
||||
for key, value := range overrides {
|
||||
// HTTP header names are case-insensitive and request construction
|
||||
// canonicalizes them (http.Header.Set), so an override that differs from a
|
||||
// base key only in case must replace that key's value in place — otherwise
|
||||
// both survive as distinct map entries and race non-deterministically at
|
||||
// build time. Keep the existing spelling (the catalog's, which the resolver
|
||||
// re-merges verbatim); only a genuinely new header is added as typed.
|
||||
if existing, ok := headerKeyFold(merged, key); ok {
|
||||
merged[existing] = value
|
||||
continue
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// headerKeyFold returns the key in headers whose canonical HTTP form matches key,
|
||||
// so a case-insensitive override lands on the existing entry instead of adding a
|
||||
// colliding duplicate.
|
||||
func headerKeyFold(headers map[string]string, key string) (string, bool) {
|
||||
if _, ok := headers[key]; ok {
|
||||
return key, true
|
||||
}
|
||||
canonical := http.CanonicalHeaderKey(key)
|
||||
for existing := range headers {
|
||||
if http.CanonicalHeaderKey(existing) == canonical {
|
||||
return existing, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestProviderSensitiveEnvKeys(t *testing.T) {
|
||||
resolved := config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{
|
||||
{Name: "custom", APIKeyEnv: " COMPANY_LLM_SECRET "},
|
||||
{Name: "catalog", APIKeyEnv: "OPENAI_API_KEY"},
|
||||
{Name: "inline"},
|
||||
},
|
||||
Provider: config.ProviderProfile{Name: "active", APIKeyEnv: "ACTIVE_PROVIDER_SECRET"},
|
||||
}
|
||||
want := []string{"COMPANY_LLM_SECRET", "OPENAI_API_KEY", "ACTIVE_PROVIDER_SECRET"}
|
||||
if got := providerSensitiveEnvKeys(resolved); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("providerSensitiveEnvKeys() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,54 @@ func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSetupProviderAimlapiEnvReferenceAndOverrideHeaders(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
result, err := saveSetupProvider(appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
}, tui.SetupSelection{
|
||||
CatalogID: "aimlapi",
|
||||
BaseURL: "https://staging.example/v1",
|
||||
Model: "anthropic/claude-sonnet-5",
|
||||
}, setupSaveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("saveSetupProvider() error = %v", err)
|
||||
}
|
||||
if result.Provider.APIKey != "" || result.Provider.APIKeyEnv != "AIMLAPI_API_KEY" {
|
||||
t.Fatalf("runtime profile did not retain AIMLAPI_API_KEY reference: %+v", result.Provider)
|
||||
}
|
||||
if len(result.Provider.CustomHeaders) != 0 {
|
||||
t.Fatalf("catalog headers leaked to first-run override: %#v", result.Provider.CustomHeaders)
|
||||
}
|
||||
persisted := readFileConfig(t, configPath).Providers[0]
|
||||
if persisted.APIKey != "" || persisted.APIKeyEnv != "AIMLAPI_API_KEY" || persisted.APIKeyStored {
|
||||
t.Fatalf("persisted credential source changed: %+v", persisted)
|
||||
}
|
||||
if len(persisted.CustomHeaders) != 0 {
|
||||
t.Fatalf("persisted override contains catalog headers: %#v", persisted.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSetupProviderAimlapiUsesResolvedPartnerOverride(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
result, err := saveSetupProvider(appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
}, tui.SetupSelection{
|
||||
CatalogID: "aimlapi",
|
||||
BaseURL: "https://api.aimlapi.com/v1",
|
||||
Model: "anthropic/claude-sonnet-5",
|
||||
}, setupSaveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("saveSetupProvider() error = %v", err)
|
||||
}
|
||||
if got := result.Provider.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
|
||||
t.Fatalf("runtime partner header = %q, want part_override", got)
|
||||
}
|
||||
if got := readFileConfig(t, configPath).Providers[0].CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
|
||||
t.Fatalf("persisted partner header = %q, want part_override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSetupProviderStoresCustomEndpointSelection(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig, canReenable bool) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -14,10 +14,14 @@ func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
dst.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
for name, server := range src.Servers {
|
||||
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
|
||||
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server, canReenable)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeProjectMCPConfig merges a project-scoped (lower-trust) MCP config into
|
||||
// dst. It never re-enables a server the user explicitly disabled or disabled a
|
||||
// server the user explicitly enabled: it merges with canReenable=false so the
|
||||
// trust-boundary guard in mergeMCPServer keeps the user's decision authoritative.
|
||||
func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
|
||||
if len(src.Servers) == 0 {
|
||||
return nil
|
||||
@@ -27,7 +31,7 @@ func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
|
||||
}
|
||||
for name, server := range src.Servers {
|
||||
base := dst.Servers[name]
|
||||
candidate := mergeMCPServer(base, server)
|
||||
candidate := mergeMCPServer(base, server, false)
|
||||
if projectMCPServerTargetChanges(base, server) && hasInheritedMCPCredentialMaterial(server, candidate) {
|
||||
return fmt.Errorf("project MCP server %q cannot override target while inheriting user credentials; set headers/env/oauth explicitly or use a new server name", name)
|
||||
}
|
||||
@@ -37,7 +41,11 @@ func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
|
||||
// mergeMCPServer merges a later config layer's MCP server into the base. The
|
||||
// canReenable flag marks the CLI-override scope, the only layer that is allowed
|
||||
// to lift a sticky user-level disable/enable decision (see the disabled-handling
|
||||
// block).
|
||||
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool) MCPServerConfig {
|
||||
if strings.TrimSpace(next.Type) != "" {
|
||||
base.Type = next.Type
|
||||
}
|
||||
@@ -62,8 +70,26 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig
|
||||
if next.OAuth != nil {
|
||||
base.OAuth = next.OAuth
|
||||
}
|
||||
// Capture the higher-trust scope's prior decision before folding in
|
||||
// next.disabledSet. The trust boundary must be evaluated against the state
|
||||
// the higher-trust layer actually left behind, not against next's own flag.
|
||||
baseDisabledSet := base.disabledSet
|
||||
baseDisabled := base.Disabled
|
||||
if next.disabledSet {
|
||||
base.disabledSet = true
|
||||
}
|
||||
if next.disabledSet || next.Disabled {
|
||||
base.Disabled = next.Disabled
|
||||
// Trust boundary: once a higher-trust scope has explicitly set the
|
||||
// disabled flag, a lower-trust layer (project config) cannot override
|
||||
// that decision in either direction — the user's choice (whether to
|
||||
// enable or disable a server) wins over the repo. This blocks a repo
|
||||
// from re-enabling a server the user disabled, and from disabling a
|
||||
// server the user explicitly enabled. The only layer allowed to
|
||||
// override an explicit higher-scope decision is the CLI override scope
|
||||
// (canReenable=true).
|
||||
if canReenable || !(baseDisabledSet && baseDisabled != next.Disabled) {
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
}
|
||||
if next.configured {
|
||||
base.configured = true
|
||||
@@ -75,7 +101,7 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig
|
||||
}
|
||||
|
||||
func projectMCPServerTargetChanges(base MCPServerConfig, next MCPServerConfig) bool {
|
||||
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next)) {
|
||||
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next, true)) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(next.URL) != "" && strings.TrimSpace(next.URL) != strings.TrimSpace(base.URL) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
@@ -170,7 +171,10 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
if err != nil {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
mergeMCPConfig(&cfg.MCP, fileConfig.MCP)
|
||||
// User config is higher-trust than project config: it may re-enable a
|
||||
// server the user disabled (a user-level disable is sticky, but the user
|
||||
// scope itself may lift it).
|
||||
mergeMCPConfig(&cfg.MCP, fileConfig.MCP, true)
|
||||
}
|
||||
// Drop the project layer when the workspace is untrusted, so a cloned repo's
|
||||
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
|
||||
@@ -184,7 +188,7 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
}
|
||||
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP)
|
||||
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP, true)
|
||||
return cfg.MCP, nil
|
||||
}
|
||||
|
||||
@@ -211,7 +215,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
for _, provider := range src.Providers {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP)
|
||||
mergeMCPConfig(&dst.MCP, src.MCP, true)
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
@@ -709,7 +713,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if hasProviderFields(overrides.Provider) {
|
||||
mergeProvider(cfg, overrides.Provider)
|
||||
}
|
||||
mergeMCPConfig(&cfg.MCP, overrides.MCP)
|
||||
mergeMCPConfig(&cfg.MCP, overrides.MCP, true)
|
||||
}
|
||||
|
||||
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
|
||||
@@ -1039,6 +1043,45 @@ func applyCatalogDescriptor(profile *ProviderProfile, descriptor providercatalog
|
||||
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && (!explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)) {
|
||||
profile.APIKeyEnv = descriptor.AuthEnvVars[0]
|
||||
}
|
||||
canonicalCatalogEndpoint := !explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
|
||||
if len(descriptor.CustomHeaders) > 0 && canonicalCatalogEndpoint {
|
||||
catalogHeaders := descriptor.CustomHeaders
|
||||
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
|
||||
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
|
||||
}
|
||||
merged := copyStringMap(catalogHeaders)
|
||||
for key, value := range profile.CustomHeaders {
|
||||
// Header names are case-insensitive. Preserve the catalog spelling while
|
||||
// replacing its value so request construction cannot see two colliding
|
||||
// map entries whose eventual winner depends on iteration order.
|
||||
existingKey := ""
|
||||
for candidate := range merged {
|
||||
if strings.EqualFold(candidate, key) {
|
||||
existingKey = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if existingKey != "" {
|
||||
merged[existingKey] = value
|
||||
continue
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
profile.CustomHeaders = merged
|
||||
} else if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") && !canonicalCatalogEndpoint {
|
||||
// AIMLAPI attribution is owned by the catalog endpoint. A profile can retain
|
||||
// those generated headers after its base URL is edited; strip their names
|
||||
// before sending requests to an arbitrary staging/proxy host while preserving
|
||||
// unrelated headers explicitly supplied by the user.
|
||||
for profileKey := range profile.CustomHeaders {
|
||||
for catalogKey := range descriptor.CustomHeaders {
|
||||
if strings.EqualFold(profileKey, catalogKey) {
|
||||
delete(profile.CustomHeaders, profileKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameBaseURL(left string, right string) bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
func TestResolveAppliesLayerPrecedence(t *testing.T) {
|
||||
@@ -554,7 +555,7 @@ func TestResolveReplacesMCPServerOverlayCollections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPServerLayersCanClearAndReenable(t *testing.T) {
|
||||
func TestResolveMCPServerLayersCannotReenableUserDisabled(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
@@ -588,9 +589,11 @@ func TestResolveMCPServerLayersCanClearAndReenable(t *testing.T) {
|
||||
}
|
||||
|
||||
docs := resolved.MCP.Servers["docs"]
|
||||
if docs.Disabled {
|
||||
t.Fatalf("docs.Disabled = true, want project layer to re-enable")
|
||||
// A user-level disable is sticky: project config must not re-enable it.
|
||||
if !docs.Disabled {
|
||||
t.Fatal("docs.Disabled = false, want user-level disable to remain sticky")
|
||||
}
|
||||
// Non-disable fields from the project layer still merge normally.
|
||||
if len(docs.Args) != 0 {
|
||||
t.Fatalf("docs.Args = %#v, want project layer to clear inherited args", docs.Args)
|
||||
}
|
||||
@@ -599,6 +602,125 @@ func TestResolveMCPServerLayersCanClearAndReenable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMCPStickyDisableReenable(t *testing.T) {
|
||||
disabled := MCPServerConfig{Disabled: true, disabledSet: true}
|
||||
|
||||
// A lower-trust (project) layer must not lift a sticky user disable.
|
||||
project := MCPServerConfig{Disabled: false, disabledSet: true}
|
||||
if got := mergeMCPServer(disabled, project, false); !got.Disabled {
|
||||
t.Fatal("project layer re-enabled a sticky user disable")
|
||||
}
|
||||
|
||||
// The user scope (explicit mcp enable command / CLI override) may re-enable.
|
||||
override := MCPServerConfig{Disabled: false, disabledSet: true}
|
||||
if got := mergeMCPServer(disabled, override, true); got.Disabled {
|
||||
t.Fatal("user-scope override must be able to re-enable")
|
||||
}
|
||||
|
||||
// A user-scope disable stacked on a project attempt still sticks.
|
||||
if got := mergeMCPServer(disabled, project, false); !got.Disabled {
|
||||
t.Fatal("sticky disable must survive a project re-enable attempt")
|
||||
}
|
||||
|
||||
// Baseline: a project layer may disable a default-enabled server when no
|
||||
// higher-trust scope has explicitly set disabled (the trust boundary must
|
||||
// not block legitimate project disables). Regression guard for the
|
||||
// disabledSet-before-check ordering bug.
|
||||
defaultEnabled := MCPServerConfig{Disabled: false, disabledSet: false}
|
||||
projectDisable := MCPServerConfig{Disabled: true, disabledSet: true}
|
||||
if got := mergeMCPServer(defaultEnabled, projectDisable, false); !got.Disabled {
|
||||
t.Fatal("project layer should be able to disable a default-enabled server")
|
||||
}
|
||||
|
||||
// And an empty project layer must not flip an unconfigured server to disabled.
|
||||
if got := mergeMCPServer(MCPServerConfig{}, MCPServerConfig{}, false); got.Disabled {
|
||||
t.Fatal("an empty project layer must not disable an unconfigured server")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveMCPCannotReenableUserDisabled exercises the trust boundary at the
|
||||
// actual ResolveMCP entry point: a project config (project scope) must not lift
|
||||
// a disable the user set in their higher-trust user config.
|
||||
func TestResolveMCPCannotReenableUserDisabled(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
// Project config tries to re-enable it.
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcpServers": {
|
||||
"docs": {
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := ResolveMCP(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP() error = %v", err)
|
||||
}
|
||||
|
||||
docs, ok := resolved.Servers["docs"]
|
||||
if !ok {
|
||||
t.Fatal("docs server missing from resolved config")
|
||||
}
|
||||
if !docs.Disabled {
|
||||
t.Fatalf("docs.Disabled = false, want user-level disable to remain sticky across project re-enable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveMCPUserLiftsProjectDisabled asserts the reverse direction is open:
|
||||
// a user config (higher-trust scope) re-enabling a server the project config
|
||||
// disabled must be permitted, since user > project in the trust hierarchy.
|
||||
func TestResolveMCPUserLiftsProjectDisabled(t *testing.T) {
|
||||
// Project config disables the server.
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcpServers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}`)
|
||||
// User config re-enables it.
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := ResolveMCP(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP() error = %v", err)
|
||||
}
|
||||
|
||||
docs, ok := resolved.Servers["docs"]
|
||||
if !ok {
|
||||
t.Fatal("docs server missing from resolved config")
|
||||
}
|
||||
if docs.Disabled {
|
||||
t.Fatalf("docs.Disabled = true, want user scope to lift the project-level disable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsDuplicateMCPRootAliases(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"mcpServers": {
|
||||
@@ -1374,6 +1496,71 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCatalogDescriptorFoldsCustomHeadersCaseInsensitively(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_env")
|
||||
descriptor, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := ProviderProfile{
|
||||
BaseURL: descriptor.DefaultBaseURL,
|
||||
CustomHeaders: map[string]string{
|
||||
"x-aimlapi-partner-id": "part_override",
|
||||
},
|
||||
}
|
||||
|
||||
applyCatalogDescriptor(&profile, descriptor, true)
|
||||
|
||||
if got := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
|
||||
t.Fatalf("canonical header value = %q, want override", got)
|
||||
}
|
||||
if _, duplicate := profile.CustomHeaders["x-aimlapi-partner-id"]; duplicate {
|
||||
t.Fatalf("case-colliding header survived merge: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCatalogDescriptorUsesAimlapiPartnerEnvironmentOverride(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_env")
|
||||
descriptor, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := ProviderProfile{BaseURL: descriptor.DefaultBaseURL}
|
||||
|
||||
applyCatalogDescriptor(&profile, descriptor, true)
|
||||
|
||||
if got := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_env" {
|
||||
t.Fatalf("partner header = %q, want part_env", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCatalogDescriptorStripsAimlapiAttributionFromRetargetedProfile(t *testing.T) {
|
||||
descriptor, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := ProviderProfile{
|
||||
BaseURL: "https://proxy.example.test/v1",
|
||||
CustomHeaders: map[string]string{
|
||||
"x-aimlapi-partner-id": "persisted-partner",
|
||||
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
|
||||
"X-AIMLAPI-Integration-Version": "zero",
|
||||
"X-Environment": "staging",
|
||||
},
|
||||
}
|
||||
|
||||
applyCatalogDescriptor(&profile, descriptor, true)
|
||||
|
||||
for key := range profile.CustomHeaders {
|
||||
if strings.HasPrefix(strings.ToLower(key), "x-aimlapi-") {
|
||||
t.Fatalf("catalog attribution survived retargeting: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
if profile.CustomHeaders["X-Environment"] != "staging" {
|
||||
t.Fatalf("user header was removed: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderProfileParseThinkTagsFalseAlias(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
|
||||
var ErrUnknownProvider = errors.New("unknown provider")
|
||||
|
||||
// AIMLAPIID is the provider catalog identifier for the AI/ML API preset.
|
||||
// AIMLAPIID is the provider catalog identifier for the aimlapi.com preset.
|
||||
const AIMLAPIID = "aimlapi"
|
||||
|
||||
type Descriptor struct {
|
||||
@@ -44,6 +44,7 @@ type Descriptor struct {
|
||||
DefaultBaseURL string
|
||||
DefaultModel string
|
||||
AuthEnvVars []string
|
||||
CustomHeaders map[string]string
|
||||
RequiresAuth bool
|
||||
UsesAmbientAuth bool
|
||||
Public bool
|
||||
@@ -70,9 +71,9 @@ type Descriptor struct {
|
||||
// headless / SSH use) in addition to the browser flow.
|
||||
OAuthDeviceFlow bool
|
||||
|
||||
// Recommended marks the provider that should be surfaced first and badged
|
||||
// (★ … (recommended)) in every catalog-ordered list and picker. At most one
|
||||
// descriptor should set this.
|
||||
// Recommended marks a provider surfaced at the top and badged in
|
||||
// catalog-ordered lists and pickers. The recommended descriptors are the first
|
||||
// entries in the catalog, in order.
|
||||
Recommended bool
|
||||
}
|
||||
|
||||
@@ -103,6 +104,11 @@ var descriptors = []Descriptor{
|
||||
// minimax, qwen, google, nvidia, tencent, z-ai). Flat /v1/chat/completions
|
||||
// with a Bearer ogw_live_… key; listed first and badged in every picker.
|
||||
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
|
||||
// aimlapi.com — OpenAI-compatible aggregating gateway, also badged as
|
||||
// recommended (second, right after the OpenGateway default). The built-in TUI
|
||||
// onboarding can register/top up an account and save the issued key; the partner
|
||||
// headers attribute usage for the rebate.
|
||||
recommended(aimlapi()),
|
||||
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
|
||||
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
|
||||
google("google", "Google", "https://generativelanguage.googleapis.com", "gemini-2.5-pro", []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, "gemini"),
|
||||
@@ -110,7 +116,6 @@ var descriptors = []Descriptor{
|
||||
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
|
||||
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
|
||||
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
|
||||
openAICompat(AIMLAPIID, "AI/ML API", "https://api.aimlapi.com/v1", "openai/gpt-5-chat", []string{"AIMLAPI_API_KEY"}, "aiml api", "ai/ml api"),
|
||||
// Hugging Face Inference Providers — OpenAI-compatible router at
|
||||
// https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth
|
||||
// requires a one-time app registration at huggingface.co/settings/applications/new
|
||||
@@ -302,8 +307,21 @@ func google(id string, name string, baseURL string, model string, env []string,
|
||||
}
|
||||
}
|
||||
|
||||
// recommended marks a descriptor as the recommended default so list/picker
|
||||
// surfaces sort it first and render the ★ … (recommended) badge.
|
||||
func aimlapi() Descriptor {
|
||||
descriptor := openAICompat(AIMLAPIID, "aimlapi.com", "https://api.aimlapi.com/v1", "anthropic/claude-sonnet-5", []string{"AIMLAPI_API_KEY"}, "aimlapi", "aiml api", "ai/ml api", "ai ml api")
|
||||
descriptor.CustomHeaders = map[string]string{
|
||||
"X-AIMLAPI-Partner-ID": "part_62yQoGYDq4Yqnrj2R1iGrDNJ",
|
||||
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
|
||||
"X-AIMLAPI-Integration-Version": "zero",
|
||||
}
|
||||
// Onboarding is key-based only: the TUI sub-flow takes an existing key or an
|
||||
// email top-up (internal/aimlapi Paths A/B). Not flagged
|
||||
// OAuth-capable, so it does not appear in the wizard's "Sign in with OAuth" list.
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// recommended marks a descriptor as a recommended default so list/picker
|
||||
// surfaces sort it to the top and render a provider-specific badge.
|
||||
func recommended(descriptor Descriptor) Descriptor {
|
||||
descriptor.Recommended = true
|
||||
return descriptor
|
||||
@@ -384,5 +402,19 @@ func cloneDescriptor(descriptor Descriptor) Descriptor {
|
||||
descriptor.AuthEnvVars = append([]string{}, descriptor.AuthEnvVars...)
|
||||
descriptor.SupportedAPIFormats = append([]APIFormat{}, descriptor.SupportedAPIFormats...)
|
||||
descriptor.Aliases = append([]string{}, descriptor.Aliases...)
|
||||
if descriptor.CustomHeaders != nil {
|
||||
descriptor.CustomHeaders = copyStringMap(descriptor.CustomHeaders)
|
||||
}
|
||||
return descriptor
|
||||
}
|
||||
|
||||
func copyStringMap(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
var expectedCatalogIDs = []string{
|
||||
"gitlawb-opengateway",
|
||||
"aimlapi",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
@@ -16,7 +17,6 @@ var expectedCatalogIDs = []string{
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"openrouter",
|
||||
"aimlapi",
|
||||
"huggingface",
|
||||
"chatgpt",
|
||||
"groq",
|
||||
@@ -69,25 +69,31 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendedProviderIsFirstAndUnique(t *testing.T) {
|
||||
func TestRecommendedProvidersAreTopOfCatalog(t *testing.T) {
|
||||
descriptors := All()
|
||||
if len(descriptors) == 0 {
|
||||
t.Fatal("All() returned no descriptors")
|
||||
// The recommended providers are badged and pinned to the top of the catalog,
|
||||
// in this order (OpenGateway remains the default; aimlapi.com is also badged).
|
||||
wantTop := []string{"gitlawb-opengateway", "aimlapi"}
|
||||
if len(descriptors) < len(wantTop) {
|
||||
t.Fatalf("All() returned %d descriptors, want at least %d", len(descriptors), len(wantTop))
|
||||
}
|
||||
if !descriptors[0].Recommended {
|
||||
t.Fatalf("All()[0] = %q, want it to be the recommended provider", descriptors[0].ID)
|
||||
}
|
||||
if descriptors[0].ID != "gitlawb-opengateway" {
|
||||
t.Fatalf("recommended provider = %q, want %q", descriptors[0].ID, "gitlawb-opengateway")
|
||||
for index, id := range wantTop {
|
||||
if descriptors[index].ID != id {
|
||||
t.Fatalf("descriptors[%d] = %q, want %q", index, descriptors[index].ID, id)
|
||||
}
|
||||
if !descriptors[index].Recommended {
|
||||
t.Fatalf("descriptors[%d] (%q) should be recommended", index, id)
|
||||
}
|
||||
}
|
||||
// Exactly those are recommended, and they are contiguous at the top.
|
||||
recommended := 0
|
||||
for _, descriptor := range descriptors {
|
||||
if descriptor.Recommended {
|
||||
recommended++
|
||||
}
|
||||
}
|
||||
if recommended != 1 {
|
||||
t.Fatalf("recommended descriptor count = %d, want exactly 1", recommended)
|
||||
if recommended != len(wantTop) {
|
||||
t.Fatalf("recommended descriptor count = %d, want %d", recommended, len(wantTop))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,14 +115,14 @@ func TestAIMLAPIDescriptor(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Require(aimlapi) error = %v", err)
|
||||
}
|
||||
if descriptor.Name != "AI/ML API" {
|
||||
t.Fatalf("Name = %q, want AI/ML API", descriptor.Name)
|
||||
if descriptor.Name != "aimlapi.com" {
|
||||
t.Fatalf("Name = %q, want aimlapi.com", descriptor.Name)
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.aimlapi.com/v1" {
|
||||
t.Fatalf("DefaultBaseURL = %q, want AI/ML API endpoint", descriptor.DefaultBaseURL)
|
||||
t.Fatalf("DefaultBaseURL = %q, want aimlapi.com endpoint", descriptor.DefaultBaseURL)
|
||||
}
|
||||
if descriptor.DefaultModel != "openai/gpt-5-chat" {
|
||||
t.Fatalf("DefaultModel = %q, want openai/gpt-5-chat", descriptor.DefaultModel)
|
||||
if descriptor.DefaultModel != "anthropic/claude-sonnet-5" {
|
||||
t.Fatalf("DefaultModel = %q, want anthropic/claude-sonnet-5", descriptor.DefaultModel)
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
@@ -316,7 +322,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "aimlapi", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -61,6 +61,17 @@ var zaiCuratedModels = []Model{
|
||||
}
|
||||
|
||||
var curatedModels = map[string][]Model{
|
||||
// aimlapi.com aggregates every upstream provider behind one OpenAI-compatible
|
||||
// endpoint, so the curated defaults are a cross-provider "best of" flagship
|
||||
// list. The live /v1/models discovery (when a key is present) replaces this;
|
||||
// these are the offline/pre-key fallback and the picker's default order.
|
||||
"aimlapi": {
|
||||
{ID: "anthropic/claude-sonnet-5", Description: "catalog default (Anthropic Claude Sonnet 5)"},
|
||||
{ID: "google/gemini-3.5-flash", Description: "Google Gemini Flash 3.5"},
|
||||
{ID: "openai/gpt-5.5-2026-04-23", Description: "OpenAI GPT-5.5"},
|
||||
{ID: "qwen/qwen-3.7-max", Description: "Qwen 3.7 Max"},
|
||||
{ID: "deepseek/deepseek-v4-pro", Description: "DeepSeek V4"},
|
||||
},
|
||||
"ollama-cloud": {
|
||||
{ID: "qwen3-coder:480b", Description: "catalog default"},
|
||||
{ID: "gpt-oss:120b", Description: "agentic coding model"},
|
||||
|
||||
@@ -33,6 +33,11 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
|
||||
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
|
||||
},
|
||||
{
|
||||
provider: "aimlapi",
|
||||
want: []string{"anthropic/claude-sonnet-5", "google/gemini-3.5-flash", "openai/gpt-5.5-2026-04-23", "qwen/qwen-3.7-max", "deepseek/deepseek-v4-pro"},
|
||||
notWant: []string{"gpt-4o", "openai/gpt-4o", "anthropic/claude-opus-4.8", "x-ai/grok-4-5"},
|
||||
},
|
||||
{
|
||||
provider: "mistral",
|
||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||
|
||||
+23
-18
@@ -14,18 +14,22 @@ type EngineOptions struct {
|
||||
Store *GrantStore
|
||||
Backend Backend
|
||||
Scope *Scope
|
||||
// SensitiveEnvKeys adds config-derived credential variable names to the
|
||||
// catalog and namespace secrets scrubbed from sandboxed commands.
|
||||
SensitiveEnvKeys []string
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
workspaceRoot string
|
||||
policy Policy
|
||||
store *GrantStore
|
||||
backend Backend
|
||||
scope *Scope
|
||||
sessionGrants *memoryGrantSet
|
||||
sessionProfiles *permissionProfileGrantSet
|
||||
turnProfiles *permissionProfileGrantSet
|
||||
commandPrefixes *commandPrefixGrantSet
|
||||
workspaceRoot string
|
||||
policy Policy
|
||||
store *GrantStore
|
||||
backend Backend
|
||||
scope *Scope
|
||||
sensitiveEnvKeys []string
|
||||
sessionGrants *memoryGrantSet
|
||||
sessionProfiles *permissionProfileGrantSet
|
||||
turnProfiles *permissionProfileGrantSet
|
||||
commandPrefixes *commandPrefixGrantSet
|
||||
}
|
||||
|
||||
func NewEngine(options EngineOptions) *Engine {
|
||||
@@ -49,15 +53,16 @@ func NewEngine(options EngineOptions) *Engine {
|
||||
scope = newScopeBestEffort(workspaceRoot)
|
||||
}
|
||||
return &Engine{
|
||||
workspaceRoot: workspaceRoot,
|
||||
policy: policy,
|
||||
store: options.Store,
|
||||
backend: options.Backend,
|
||||
scope: scope,
|
||||
sessionGrants: newMemoryGrantSet(),
|
||||
sessionProfiles: newPermissionProfileGrantSet(),
|
||||
turnProfiles: newPermissionProfileGrantSet(),
|
||||
commandPrefixes: newCommandPrefixGrantSet(),
|
||||
workspaceRoot: workspaceRoot,
|
||||
policy: policy,
|
||||
store: options.Store,
|
||||
backend: options.Backend,
|
||||
scope: scope,
|
||||
sensitiveEnvKeys: normalizeSensitiveEnvKeys(options.SensitiveEnvKeys),
|
||||
sessionGrants: newMemoryGrantSet(),
|
||||
sessionProfiles: newPermissionProfileGrantSet(),
|
||||
turnProfiles: newPermissionProfileGrantSet(),
|
||||
commandPrefixes: newCommandPrefixGrantSet(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ type CommandSpec struct {
|
||||
Args []string
|
||||
Dir string
|
||||
Env []string
|
||||
// sensitiveEnvKeys is populated by Engine from config-derived credential
|
||||
// variable names and carried through SandboxManager to the platform plan.
|
||||
sensitiveEnvKeys []string
|
||||
}
|
||||
|
||||
type CommandPlan struct {
|
||||
@@ -122,6 +125,7 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
return CommandPlan{}, errors.New("sandbox command name is required")
|
||||
}
|
||||
spec.Dir = commandDir
|
||||
spec.sensitiveEnvKeys = engine.sensitiveEnvKeys
|
||||
|
||||
backend := engine.backend
|
||||
if backend.Name == "" {
|
||||
@@ -207,7 +211,7 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
env := sandboxEnvironmentForCommand(spec.Env, policy, BackendLinuxBwrap, "")
|
||||
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendLinuxBwrap, "", spec.sensitiveEnvKeys)
|
||||
planDir := spec.Dir
|
||||
if helper.Dir != "" {
|
||||
planDir = helper.Dir
|
||||
@@ -253,10 +257,27 @@ func directCommandPlan(spec CommandSpec, backend Backend, policy Policy, workspa
|
||||
Name: spec.Name,
|
||||
Args: cloneStrings(spec.Args),
|
||||
Dir: spec.Dir,
|
||||
Env: cloneStrings(spec.Env),
|
||||
Env: directCommandEnv(spec),
|
||||
}
|
||||
}
|
||||
|
||||
// directCommandEnv scrubs sensitive credentials from the environment for a
|
||||
// direct (unwrapped) command plan: the platform sandbox backend is
|
||||
// unavailable, disabled, or not required, so this is the actual environment
|
||||
// the child process inherits. Without scrubbing here, config-derived
|
||||
// apiKeyEnv and dynamic OAuth client-secret variables would leak into
|
||||
// commands that fall back to this path (e.g. EnforcementDegraded).
|
||||
func directCommandEnv(spec CommandSpec) []string {
|
||||
env := cloneStrings(spec.Env)
|
||||
if spec.Env == nil {
|
||||
// Match the wrapped-plan behavior: an unset spec.Env means "inherit the
|
||||
// caller's environment," so scrub a snapshot of it rather than passing
|
||||
// nil through to exec.Cmd, which would skip scrubbing entirely.
|
||||
env = os.Environ()
|
||||
}
|
||||
return scrubSensitiveEnv(env, spec.sensitiveEnvKeys...)
|
||||
}
|
||||
|
||||
func (engine *Engine) resolveCommandDir(dir string, policy Policy) (string, string, error) {
|
||||
workspaceRoot := strings.TrimSpace(engine.workspaceRoot)
|
||||
if workspaceRoot == "" {
|
||||
@@ -318,7 +339,7 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
|
||||
if envBackend == "" {
|
||||
envBackend = BackendMacOSSeatbelt
|
||||
}
|
||||
env := sandboxEnvironmentForCommand(spec.Env, policy, envBackend, "")
|
||||
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, envBackend, "", spec.sensitiveEnvKeys)
|
||||
plan := CommandPlan{
|
||||
Backend: backend,
|
||||
TargetBackend: backend.TargetBackend(),
|
||||
@@ -377,6 +398,10 @@ func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string
|
||||
}
|
||||
|
||||
func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName, workspaceRoot string) []string {
|
||||
return sandboxEnvironmentForCommandWithSensitiveEnv(specEnv, policy, backend, workspaceRoot, nil)
|
||||
}
|
||||
|
||||
func sandboxEnvironmentForCommandWithSensitiveEnv(specEnv []string, policy Policy, backend BackendName, workspaceRoot string, sensitiveEnvKeys []string) []string {
|
||||
env := cloneStrings(specEnv)
|
||||
if specEnv == nil {
|
||||
// Preserve the caller environment for sandboxed commands. The sandbox
|
||||
@@ -384,7 +409,7 @@ func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend Backe
|
||||
// command env values still replace inherited values below.
|
||||
env = os.Environ()
|
||||
}
|
||||
env = scrubSensitiveEnv(env)
|
||||
env = scrubSensitiveEnv(env, sensitiveEnvKeys...)
|
||||
pathValue := envListValue(env, "PATH", defaultPath())
|
||||
if runtime.GOOS == "darwin" {
|
||||
// Preserve standard user tool locations so a bare `python3`/`node`
|
||||
@@ -967,7 +992,7 @@ func regexpQuoteMeta(value string) string {
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func scrubSensitiveEnv(env []string) []string {
|
||||
func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
|
||||
// Secrets not covered by the provider catalog: cloud/VCS credentials and
|
||||
// providers Zero talks to through generic OpenAI-compatible endpoints.
|
||||
sensitiveKeys := []string{
|
||||
@@ -997,6 +1022,7 @@ func scrubSensitiveEnv(env []string) []string {
|
||||
sensitiveKeys = append(sensitiveKeys, key)
|
||||
}
|
||||
}
|
||||
sensitiveKeys = append(sensitiveKeys, normalizeSensitiveEnvKeys(additionalKeys)...)
|
||||
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
@@ -1005,7 +1031,7 @@ func scrubSensitiveEnv(env []string) []string {
|
||||
out = append(out, kv)
|
||||
continue
|
||||
}
|
||||
drop := false
|
||||
drop := isDynamicSensitiveEnvKey(key)
|
||||
for _, sensitive := range sensitiveKeys {
|
||||
if strings.EqualFold(key, sensitive) {
|
||||
drop = true
|
||||
@@ -1018,3 +1044,37 @@ func scrubSensitiveEnv(env []string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSensitiveEnvKeys(keys []string) []string {
|
||||
out := make([]string, 0, len(keys))
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
// A misconfigured value like "COMPANY_LLM_SECRET=..." would never
|
||||
// match a real env key during scrubbing; keep only the name part.
|
||||
if name, _, found := strings.Cut(key, "="); found {
|
||||
key = strings.TrimSpace(name)
|
||||
}
|
||||
folded := strings.ToUpper(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[folded]; ok {
|
||||
continue
|
||||
}
|
||||
seen[folded] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isDynamicSensitiveEnvKey(key string) bool {
|
||||
const (
|
||||
prefix = "ZERO_OAUTH_"
|
||||
suffix = "_CLIENT_SECRET"
|
||||
)
|
||||
key = strings.ToUpper(strings.TrimSpace(key))
|
||||
return strings.HasPrefix(key, prefix) &&
|
||||
strings.HasSuffix(key, suffix) &&
|
||||
len(key) > len(prefix)+len(suffix)
|
||||
}
|
||||
@@ -122,6 +122,47 @@ func TestBuildCommandPlanDegradesUnavailableFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCommandPlanDegradedFallbackScrubsInheritedEnv covers the
|
||||
// EnforcementDegraded fallback path: when the native backend is
|
||||
// unavailable, BuildCommandPlan falls back to a direct (unwrapped) plan
|
||||
// whose spec.Env is nil, so exec.Cmd would otherwise inherit the caller's
|
||||
// environment — including configured and dynamically named credentials —
|
||||
// unscrubbed.
|
||||
func TestBuildCommandPlanDegradedFallbackScrubsInheritedEnv(t *testing.T) {
|
||||
t.Setenv("COMPANY_LLM_SECRET", "custom-secret")
|
||||
t.Setenv("ZERO_OAUTH_ACME_CLIENT_SECRET", "oauth-secret")
|
||||
t.Setenv("SAFE_VAR", "hello")
|
||||
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
SensitiveEnvKeys: []string{"COMPANY_LLM_SECRET"},
|
||||
})
|
||||
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", "pwd"},
|
||||
Dir: root,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded {
|
||||
t.Fatalf("plan = %#v, want degraded direct plan", plan)
|
||||
}
|
||||
for _, entry := range plan.Env {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if strings.EqualFold(key, "COMPANY_LLM_SECRET") || strings.EqualFold(key, "ZERO_OAUTH_ACME_CLIENT_SECRET") {
|
||||
t.Fatalf("degraded plan.Env retained sensitive key %q: %v", key, plan.Env)
|
||||
}
|
||||
}
|
||||
if got := envListValue(plan.Env, "SAFE_VAR", ""); got != "hello" {
|
||||
t.Fatalf("SAFE_VAR = %q, want hello", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandPlanRejectsOutsideDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
@@ -653,14 +694,19 @@ func TestScrubSensitiveEnv(t *testing.T) {
|
||||
"XAI_API_KEY=xai-12345",
|
||||
"HUGGINGFACE_API_KEY=hf_12345",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS=/home/user/sa-key.json",
|
||||
"COMPANY_LLM_SECRET=custom-secret",
|
||||
"ZERO_OAUTH_MY_SVC_CLIENT_SECRET=oauth-secret",
|
||||
"zero_oauth_second_client_secret=case-insensitive-secret",
|
||||
"ZERO_OAUTH_CLIENT_SECRET=not-a-provider-secret",
|
||||
"AWS_PROFILE=staging",
|
||||
"SAFE_VAR=hello",
|
||||
}
|
||||
scrubbed := scrubSensitiveEnv(inputEnv)
|
||||
scrubbed := scrubSensitiveEnv(inputEnv, " COMPANY_LLM_SECRET ", "company_llm_secret", "GITHUB_TOKEN=ghp_pasted-assignment", "=", "")
|
||||
expected := map[string]string{
|
||||
"PATH": "/usr/bin",
|
||||
"SAFE_VAR": "hello",
|
||||
"AWS_PROFILE": "staging",
|
||||
"PATH": "/usr/bin",
|
||||
"SAFE_VAR": "hello",
|
||||
"AWS_PROFILE": "staging",
|
||||
"ZERO_OAUTH_CLIENT_SECRET": "not-a-provider-secret",
|
||||
}
|
||||
actual := make(map[string]string, len(scrubbed))
|
||||
for _, entry := range scrubbed {
|
||||
@@ -674,3 +720,34 @@ func TestScrubSensitiveEnv(t *testing.T) {
|
||||
t.Errorf("scrubSensitiveEnv() = %v, want %v", actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineScrubsConfiguredSensitiveEnvKeys(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"},
|
||||
SensitiveEnvKeys: []string{"COMPANY_LLM_SECRET"},
|
||||
})
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{
|
||||
Name: "true",
|
||||
Env: []string{
|
||||
"PATH=/usr/bin",
|
||||
"COMPANY_LLM_SECRET=custom-secret",
|
||||
"ZERO_OAUTH_CUSTOM_CLIENT_SECRET=oauth-secret",
|
||||
"SAFE_VAR=hello",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
for _, entry := range plan.Env {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if strings.EqualFold(key, "COMPANY_LLM_SECRET") || strings.EqualFold(key, "ZERO_OAUTH_CUSTOM_CLIENT_SECRET") {
|
||||
t.Fatalf("plan.Env retained sensitive key %q: %v", key, plan.Env)
|
||||
}
|
||||
}
|
||||
if got := envListValue(plan.Env, "SAFE_VAR", ""); got != "hello" {
|
||||
t.Fatalf("SAFE_VAR = %q, want hello", got)
|
||||
}
|
||||
}
|
||||
@@ -326,7 +326,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
childEnv := sandboxEnvironmentForCommand(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot)
|
||||
childEnv := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot, spec.sensitiveEnvKeys)
|
||||
// The unelevated enforcement tier maps to the runner's unelevated level: same
|
||||
// restricted token, but the runner applies the workspace ACLs itself instead
|
||||
// of requiring the elevated setup marker.
|
||||
|
||||
@@ -34,7 +34,8 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"patch"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
|
||||
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: applyPatchResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -88,6 +88,8 @@ func NewAskUserTool() *askUserTool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Asks the user clarifying questions; gathers input only."),
|
||||
// EffectInteractive: blocks on user input; ordering matters.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
|
||||
// One-shot shell can rewrite the workspace (and more). Serialized
|
||||
// as WorkspaceWrite so external-mutator audits include bash; not
|
||||
// session-bound Interactive (that is reserved for PTY/terminal tools).
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package tools
|
||||
|
||||
import "github.com/Gitlawb/zero/internal/localcontrol"
|
||||
|
||||
// BuiltinCatalog returns every built-in tool that Zero can expose to the model
|
||||
// from this package (core + control + optional local-control helpers).
|
||||
//
|
||||
// It is the source of truth for metadata coverage tests: every tool returned
|
||||
// here must declare an explicit Effect other than EffectUnknown.
|
||||
//
|
||||
// Not included (fail-closed EffectUnknown by design):
|
||||
// - MCP tools (internal/mcp registryTool)
|
||||
// - Plugin tools (internal/plugins pluginTool)
|
||||
// - Specialist tools registered outside this package (Task, Generate, …)
|
||||
//
|
||||
// workspaceRoot is used only to construct path-scoped tools; it need not exist.
|
||||
func BuiltinCatalog(workspaceRoot string) []Tool {
|
||||
if workspaceRoot == "" {
|
||||
workspaceRoot = "."
|
||||
}
|
||||
var all []Tool
|
||||
all = append(all, CoreReadOnlyTools(workspaceRoot)...)
|
||||
all = append(all, CoreWriteTools(workspaceRoot)...)
|
||||
all = append(all, CoreShellTools(workspaceRoot)...)
|
||||
// Network tools: always include web_fetch; always include web_search for
|
||||
// classification even when no backend is configured at runtime (registration
|
||||
// remains conditional in CoreNetworkTools).
|
||||
all = append(all, NewWebFetchTool(), NewWebSearchTool())
|
||||
all = append(all, NewEscalateModelTool())
|
||||
|
||||
// tool_search needs a live registry of the tools above.
|
||||
reg := NewRegistry()
|
||||
for _, tool := range all {
|
||||
reg.Register(tool)
|
||||
}
|
||||
search := NewToolSearchTool(reg)
|
||||
reg.Register(search)
|
||||
all = append(all, search)
|
||||
|
||||
// Local-control tools are feature-gated at registration time but always
|
||||
// exist as built-in implementations with fixed classifications.
|
||||
all = append(all, NewLocalBrowserTools(localcontrol.BrowserOptions{})...)
|
||||
all = append(all, NewLocalDesktopTools(localcontrol.DesktopOptions{})...)
|
||||
all = append(all, NewLocalTerminalTools(localcontrol.TerminalOptions{})...)
|
||||
all = append(all, NewLocalControlArtifactTools(LocalControlArtifactOptions{})...)
|
||||
return all
|
||||
}
|
||||
|
||||
// ValidateBuiltinCatalog returns diagnostics for every catalog tool that is
|
||||
// missing or inconsistently classified. Empty means full coverage.
|
||||
//
|
||||
// Duplicate builtin names are always rejected: ResourceKeys is a function and
|
||||
// cannot be compared, so partial metadata comparison would miss conflict-scope
|
||||
// drift between two same-name tools.
|
||||
//
|
||||
// Validation uses constructor-declared capabilities (before ThreadSafe
|
||||
// normalization) so a mutator wired with ThreadSafe=true is still reported.
|
||||
// Runtime callers continue to use CapabilitiesOf, which normalizes.
|
||||
func ValidateBuiltinCatalog(workspaceRoot string) []string {
|
||||
var problems []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, tool := range BuiltinCatalog(workspaceRoot) {
|
||||
name := tool.Name()
|
||||
if _, ok := seen[name]; ok {
|
||||
problems = append(problems, name+": duplicate builtin name in catalog")
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
|
||||
// Runtime view: must not remain Unknown after normalization.
|
||||
runtimeCaps := CapabilitiesOf(tool)
|
||||
if runtimeCaps.Effect == EffectUnknown {
|
||||
problems = append(problems, name+": built-in tool remains EffectUnknown (metadata required)")
|
||||
}
|
||||
|
||||
// Declaration view: catch invalid ThreadSafe on mutators that
|
||||
// CapabilitiesOf would silently clear.
|
||||
declared := declaredCapabilitiesOf(tool)
|
||||
problems = append(problems, ValidateCapabilities(name, declared)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// declaredCapabilitiesOf returns constructor metadata when available; otherwise
|
||||
// falls back to CapabilitiesOf (already normalized).
|
||||
func declaredCapabilitiesOf(tool Tool) ToolCapabilities {
|
||||
if tool == nil {
|
||||
return UnknownCapabilities()
|
||||
}
|
||||
if d, ok := tool.(declaredCapabilityProvider); ok {
|
||||
return d.declaredCapabilities()
|
||||
}
|
||||
return CapabilitiesOf(tool)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package tools
|
||||
|
||||
import "strings"
|
||||
|
||||
// EffectClass classifies a tool's side effects for concurrency safety.
|
||||
// Concurrent execution (a later PR) must only consider tools that are both
|
||||
// ThreadSafe and EffectReadOnly; this package only declares metadata.
|
||||
// ThreadSafe is force-cleared for every non-ReadOnly effect at read time
|
||||
// (see normalizeCapabilities).
|
||||
type EffectClass int
|
||||
|
||||
const (
|
||||
// EffectUnknown is the fail-closed default for unclassified tools.
|
||||
// Never concurrency-eligible; always serialized.
|
||||
EffectUnknown EffectClass = iota
|
||||
// EffectReadOnly reads local or remote state without mutating it.
|
||||
// The only effect class that may set ThreadSafe=true after audit.
|
||||
EffectReadOnly
|
||||
// EffectProcessRead observes a retained process, PTY, or terminal.
|
||||
EffectProcessRead
|
||||
// EffectWorkspaceWrite mutates the local repository or workspace.
|
||||
// Includes one-shot shell commands that can rewrite the workspace.
|
||||
EffectWorkspaceWrite
|
||||
// EffectExternalWrite mutates state outside the local workspace.
|
||||
EffectExternalWrite
|
||||
// EffectInteractive uses browser, PTY/terminal, prompts, or other
|
||||
// session-bound interaction where ordering matters.
|
||||
EffectInteractive
|
||||
)
|
||||
|
||||
// String returns a stable diagnostic name for the effect class.
|
||||
func (e EffectClass) String() string {
|
||||
switch e {
|
||||
case EffectUnknown:
|
||||
return "unknown"
|
||||
case EffectReadOnly:
|
||||
return "read_only"
|
||||
case EffectProcessRead:
|
||||
return "process_read"
|
||||
case EffectWorkspaceWrite:
|
||||
return "workspace_write"
|
||||
case EffectExternalWrite:
|
||||
return "external_write"
|
||||
case EffectInteractive:
|
||||
return "interactive"
|
||||
default:
|
||||
return "invalid"
|
||||
}
|
||||
}
|
||||
|
||||
// Valid reports whether e is one of the defined effect classes.
|
||||
func (e EffectClass) Valid() bool {
|
||||
return e >= EffectUnknown && e <= EffectInteractive
|
||||
}
|
||||
|
||||
// ToolCapabilities is the concurrency-safety contract for a tool.
|
||||
// ResourceKeys, when set, returns deterministic conflict keys for a call's
|
||||
// arguments. Returning no keys does NOT make a tool parallel-safe.
|
||||
type ToolCapabilities struct {
|
||||
Effect EffectClass
|
||||
ThreadSafe bool
|
||||
ResourceKeys func(args map[string]any) []string
|
||||
}
|
||||
|
||||
// CapabilityProvider is the optional interface tools implement to declare
|
||||
// effect metadata. Tools that do not implement it are treated as EffectUnknown
|
||||
// and not thread-safe (fail-closed).
|
||||
type CapabilityProvider interface {
|
||||
Capabilities() ToolCapabilities
|
||||
}
|
||||
|
||||
// UnknownCapabilities is the fail-closed default for tools without metadata.
|
||||
func UnknownCapabilities() ToolCapabilities {
|
||||
return ToolCapabilities{
|
||||
Effect: EffectUnknown,
|
||||
ThreadSafe: false,
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeCapabilities is the single read path for capability metadata.
|
||||
// ThreadSafe is cleared unless Effect is a valid EffectReadOnly — mutators,
|
||||
// interactive tools, process readers, unknown, and invalid effects must never
|
||||
// appear concurrency-eligible to a future batch planner that only checks
|
||||
// ThreadSafe && Effect.
|
||||
func normalizeCapabilities(caps ToolCapabilities) ToolCapabilities {
|
||||
if !caps.Effect.Valid() || caps.Effect != EffectReadOnly {
|
||||
caps.ThreadSafe = false
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
// CapabilitiesOf returns a tool's declared capabilities, or the fail-closed
|
||||
// unknown default when the tool does not implement CapabilityProvider.
|
||||
func CapabilitiesOf(tool Tool) ToolCapabilities {
|
||||
if tool == nil {
|
||||
return UnknownCapabilities()
|
||||
}
|
||||
if provider, ok := tool.(CapabilityProvider); ok {
|
||||
return normalizeCapabilities(provider.Capabilities())
|
||||
}
|
||||
return UnknownCapabilities()
|
||||
}
|
||||
|
||||
// (baseTool).Capabilities exposes the capabilities field set at construction,
|
||||
// normalized so ThreadSafe cannot surface on non-ReadOnly effects.
|
||||
// Tools that embed baseTool inherit this method.
|
||||
func (tool baseTool) Capabilities() ToolCapabilities {
|
||||
return normalizeCapabilities(tool.capabilities)
|
||||
}
|
||||
|
||||
// declaredCapabilities returns the constructor-set metadata without runtime
|
||||
// normalization. Catalog validation uses this so a mis-declared
|
||||
// ThreadSafe=true on a mutator is still reported even though CapabilitiesOf
|
||||
// clears it for callers.
|
||||
func (tool baseTool) declaredCapabilities() ToolCapabilities {
|
||||
return tool.capabilities
|
||||
}
|
||||
|
||||
// declaredCapabilityProvider is implemented by tools that can report their
|
||||
// raw constructor metadata (baseTool embedders).
|
||||
type declaredCapabilityProvider interface {
|
||||
declaredCapabilities() ToolCapabilities
|
||||
}
|
||||
|
||||
// Registry.Capabilities looks up a registered tool's capabilities by name.
|
||||
// Unknown tools return the fail-closed default (not an error) so callers can
|
||||
// treat missing tools as non-concurrent without panicking.
|
||||
func (registry *Registry) Capabilities(name string) ToolCapabilities {
|
||||
if registry == nil {
|
||||
return UnknownCapabilities()
|
||||
}
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
return UnknownCapabilities()
|
||||
}
|
||||
return CapabilitiesOf(tool)
|
||||
}
|
||||
|
||||
// ValidateCapabilities checks a single capability record for consistency.
|
||||
// It returns a non-empty slice of human-readable problems when invalid.
|
||||
// Note: CapabilitiesOf already normalizes ThreadSafe away for non-ReadOnly
|
||||
// effects; validation still rejects the raw combination so catalog tests
|
||||
// catch mis-declared constructors.
|
||||
func ValidateCapabilities(name string, caps ToolCapabilities) []string {
|
||||
var problems []string
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "<unnamed>"
|
||||
}
|
||||
if !caps.Effect.Valid() {
|
||||
problems = append(problems, name+": invalid effect value")
|
||||
}
|
||||
if caps.Effect == EffectUnknown && caps.ThreadSafe {
|
||||
problems = append(problems, name+": ThreadSafe=true is forbidden with EffectUnknown")
|
||||
}
|
||||
// Mutating / interactive / process-read effects are never concurrency-eligible.
|
||||
switch caps.Effect {
|
||||
case EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive, EffectProcessRead:
|
||||
if caps.ThreadSafe {
|
||||
problems = append(problems, name+": ThreadSafe=true is not permitted for effect "+caps.Effect.String())
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ValidateToolCapabilities validates CapabilitiesOf(tool) for a registered tool.
|
||||
func ValidateToolCapabilities(tool Tool) []string {
|
||||
if tool == nil {
|
||||
return []string{"<nil tool>"}
|
||||
}
|
||||
return ValidateCapabilities(tool.Name(), CapabilitiesOf(tool))
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// plainTool implements Tool without CapabilityProvider — fail-closed default.
|
||||
type plainTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (t plainTool) Name() string { return t.name }
|
||||
func (t plainTool) Description() string { return "plain" }
|
||||
func (t plainTool) Parameters() Schema { return Schema{Type: "object"} }
|
||||
func (t plainTool) Safety() Safety { return readOnlySafety("plain") }
|
||||
func (t plainTool) Run(context.Context, map[string]any) Result {
|
||||
return okResult("ok")
|
||||
}
|
||||
|
||||
func TestCapabilitiesOfDefaultsUnknown(t *testing.T) {
|
||||
caps := CapabilitiesOf(plainTool{name: "mystery"})
|
||||
if caps.Effect != EffectUnknown {
|
||||
t.Fatalf("Effect = %v, want Unknown", caps.Effect)
|
||||
}
|
||||
if caps.ThreadSafe {
|
||||
t.Fatal("ThreadSafe must be false for unclassified tools")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesOfNilTool(t *testing.T) {
|
||||
caps := CapabilitiesOf(nil)
|
||||
if caps.Effect != EffectUnknown || caps.ThreadSafe {
|
||||
t.Fatalf("nil tool = %+v, want fail-closed unknown", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownNotThreadSafeEvenIfProviderLies(t *testing.T) {
|
||||
tool := baseTool{
|
||||
name: "liar",
|
||||
capabilities: ToolCapabilities{
|
||||
Effect: EffectUnknown,
|
||||
ThreadSafe: true, // invalid — Capabilities() must clear it
|
||||
},
|
||||
}
|
||||
caps := tool.Capabilities()
|
||||
if caps.ThreadSafe {
|
||||
t.Fatal("Capabilities() must force ThreadSafe=false for EffectUnknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeClearsThreadSafeOnMutators(t *testing.T) {
|
||||
tool := baseTool{
|
||||
name: "writer",
|
||||
capabilities: ToolCapabilities{
|
||||
Effect: EffectWorkspaceWrite,
|
||||
ThreadSafe: true, // must be cleared on read
|
||||
},
|
||||
}
|
||||
if tool.Capabilities().ThreadSafe {
|
||||
t.Fatal("WorkspaceWrite must never surface ThreadSafe=true via Capabilities()")
|
||||
}
|
||||
// Wrap with Run so CapabilitiesOf's CapabilityProvider path is exercised.
|
||||
wrapped := deferredCapableTool{baseTool: tool}
|
||||
if CapabilitiesOf(wrapped).ThreadSafe {
|
||||
t.Fatal("CapabilitiesOf must normalize mutator ThreadSafe away")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsThreadSafeUnknown(t *testing.T) {
|
||||
problems := ValidateCapabilities("x", ToolCapabilities{Effect: EffectUnknown, ThreadSafe: true})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("expected validation problems for ThreadSafe+Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsThreadSafeMutators(t *testing.T) {
|
||||
for _, effect := range []EffectClass{EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive, EffectProcessRead} {
|
||||
problems := ValidateCapabilities("m", ToolCapabilities{Effect: effect, ThreadSafe: true})
|
||||
if len(problems) == 0 {
|
||||
t.Fatalf("effect %s: expected ThreadSafe rejection", effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryBuiltinHasExplicitMetadata(t *testing.T) {
|
||||
problems := ValidateBuiltinCatalog(t.TempDir())
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("builtin metadata coverage failed:\n %s", strings.Join(problems, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinCatalogNoUnknown(t *testing.T) {
|
||||
counts := map[EffectClass]int{}
|
||||
for _, tool := range BuiltinCatalog(t.TempDir()) {
|
||||
caps := CapabilitiesOf(tool)
|
||||
if caps.Effect == EffectUnknown {
|
||||
t.Errorf("tool %q is EffectUnknown", tool.Name())
|
||||
}
|
||||
counts[caps.Effect]++
|
||||
}
|
||||
if counts[EffectUnknown] != 0 {
|
||||
t.Fatalf("Unknown built-ins = %d, want 0", counts[EffectUnknown])
|
||||
}
|
||||
t.Logf("classification counts: %+v", counts)
|
||||
}
|
||||
|
||||
func TestRegistryCapabilitiesLookup(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.Register(NewScopedReadFileTool(t.TempDir(), nil))
|
||||
caps := reg.Capabilities("read_file")
|
||||
if caps.Effect != EffectReadOnly {
|
||||
t.Fatalf("read_file effect = %v, want ReadOnly", caps.Effect)
|
||||
}
|
||||
// Missing tool → fail-closed.
|
||||
missing := reg.Capabilities("does_not_exist")
|
||||
if missing.Effect != EffectUnknown || missing.ThreadSafe {
|
||||
t.Fatalf("missing tool = %+v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResourceKeysNormalized(t *testing.T) {
|
||||
keys := fileResourceKeys(map[string]any{"path": `./src/../src/main.go`})
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("keys = %v", keys)
|
||||
}
|
||||
want := ResourceKeyFile + NormalizeResourcePath("./src/../src/main.go")
|
||||
if keys[0] != want {
|
||||
t.Fatalf("key = %q, want %q", keys[0], want)
|
||||
}
|
||||
// Missing args → nil, no panic.
|
||||
if fileResourceKeys(nil) != nil {
|
||||
t.Fatal("nil args should yield nil keys")
|
||||
}
|
||||
if fileResourceKeys(map[string]any{}) != nil {
|
||||
t.Fatal("empty args should yield nil keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePathNormalizePlatform(t *testing.T) {
|
||||
// Unix-style clean
|
||||
got := NormalizeResourcePath("a//b/../c")
|
||||
if got != "a/c" && got != filepath.ToSlash(filepath.Clean("a//b/../c")) {
|
||||
// After ToSlash clean should be a/c
|
||||
if NormalizeResourcePath("a//b/../c") != "a/c" {
|
||||
t.Fatalf("unix clean = %q", got)
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// Case-insensitive
|
||||
if NormalizeResourcePath(`C:\Foo\Bar`) != NormalizeResourcePath(`c:\foo\bar`) {
|
||||
t.Fatalf("windows case fold failed: %q vs %q",
|
||||
NormalizeResourcePath(`C:\Foo\Bar`), NormalizeResourcePath(`c:\foo\bar`))
|
||||
}
|
||||
}
|
||||
// Empty / URL-shaped
|
||||
if NormalizeResourcePath("") != "" {
|
||||
t.Fatal("empty path")
|
||||
}
|
||||
if NormalizeResourcePath("https://user:pass@host/path") != "" {
|
||||
t.Fatal("URL must not become a file resource path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointResourceKeysStripSecrets(t *testing.T) {
|
||||
keys := endpointResourceKeys(map[string]any{
|
||||
"url": "https://user:[email protected]:443/v1?token=abc",
|
||||
})
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("keys = %v", keys)
|
||||
}
|
||||
if strings.Contains(keys[0], "secret") || strings.Contains(keys[0], "token") || strings.Contains(keys[0], "user") {
|
||||
t.Fatalf("endpoint key leaked secret material: %q", keys[0])
|
||||
}
|
||||
if !strings.Contains(keys[0], "api.example.com") {
|
||||
t.Fatalf("endpoint key missing host: %q", keys[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointResourceKeysAtInQueryDoesNotStealHost(t *testing.T) {
|
||||
// Path/query must be stripped BEFORE userinfo so ?x=@bad cannot become the host.
|
||||
keys := endpointResourceKeys(map[string]any{
|
||||
"url": "https://api.example.com/v1?token=@secret",
|
||||
})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyEndpoint+"api.example.com" {
|
||||
t.Fatalf("keys = %v, want endpoint:api.example.com", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessResourceKeysNumericSessionID(t *testing.T) {
|
||||
// write_stdin declares session_id as integer; JSON decodes as float64.
|
||||
keys := processResourceKeys(map[string]any{"session_id": float64(42)})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyProcess+"42" {
|
||||
t.Fatalf("keys = %v, want process:42", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessResourceKeysSessionArg(t *testing.T) {
|
||||
// terminal_session requires "session", not session_id.
|
||||
keys := processResourceKeys(map[string]any{"session": "term-7"})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyProcess+"term-7" {
|
||||
t.Fatalf("keys = %v, want process:term-7", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
|
||||
patch := "--- a/old.go\n+++ b/new.go\n@@ -1 +1 @@\n-x\n+y\n"
|
||||
keys := applyPatchResourceKeys(map[string]any{
|
||||
"patch": patch,
|
||||
"cwd": "pkg",
|
||||
})
|
||||
// Diff paths must be joined under cwd so the same basename under different
|
||||
// cwds does not collide for the future conflict planner.
|
||||
want := map[string]bool{
|
||||
ResourceKeyDirectory + "pkg": true,
|
||||
ResourceKeyFile + "pkg/old.go": true,
|
||||
ResourceKeyFile + "pkg/new.go": true,
|
||||
}
|
||||
for _, k := range keys {
|
||||
if !want[k] {
|
||||
t.Fatalf("unexpected key %q in %v", k, keys)
|
||||
}
|
||||
delete(want, k)
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("missing keys %v from %v", want, keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
|
||||
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
|
||||
// must still see the raw declaration so miswired constructors fail the gate.
|
||||
tool := deferredCapableTool{baseTool: baseTool{
|
||||
name: "bad_writer",
|
||||
capabilities: ToolCapabilities{
|
||||
Effect: EffectWorkspaceWrite,
|
||||
ThreadSafe: true,
|
||||
},
|
||||
}}
|
||||
declared := declaredCapabilitiesOf(tool)
|
||||
if !declared.ThreadSafe || declared.Effect != EffectWorkspaceWrite {
|
||||
t.Fatalf("declared = %+v, want raw WorkspaceWrite+ThreadSafe", declared)
|
||||
}
|
||||
problems := ValidateCapabilities(tool.Name(), declared)
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("expected ValidateCapabilities to reject raw WorkspaceWrite+ThreadSafe")
|
||||
}
|
||||
// Runtime view stays safe.
|
||||
if CapabilitiesOf(tool).ThreadSafe {
|
||||
t.Fatal("runtime CapabilitiesOf must still clear ThreadSafe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchResourceKeysFallbackWorkspace(t *testing.T) {
|
||||
keys := applyPatchResourceKeys(map[string]any{"patch": "not a real diff"})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyWorkspace+"root" {
|
||||
t.Fatalf("keys = %v, want workspace:root fallback", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceWriteClassifications(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, tool := range []Tool{
|
||||
NewScopedWriteFileTool(root, nil),
|
||||
NewScopedEditFileTool(root, nil),
|
||||
NewScopedApplyPatchTool(root, nil),
|
||||
NewScopedBashTool(root, nil),
|
||||
} {
|
||||
if got := CapabilitiesOf(tool).Effect; got != EffectWorkspaceWrite {
|
||||
t.Errorf("%s effect = %v, want WorkspaceWrite", tool.Name(), got)
|
||||
}
|
||||
if CapabilitiesOf(tool).ThreadSafe {
|
||||
t.Errorf("%s must not be ThreadSafe", tool.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveClassifications(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"ask_user", "exec_command", "write_stdin", "update_plan", "escalate_model", "request_permissions", "browser_open", "terminal_session", "desktop_action"} {
|
||||
var found Tool
|
||||
for _, tool := range BuiltinCatalog(root) {
|
||||
if tool.Name() == name {
|
||||
found = tool
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("tool %q not in catalog", name)
|
||||
}
|
||||
if got := CapabilitiesOf(found).Effect; got != EffectInteractive {
|
||||
t.Errorf("%s effect = %v, want Interactive", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyClassifications(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"read_file", "read_minified_file", "list_directory", "glob", "grep", "lsp_navigate", "skill", "web_fetch", "web_search", "tool_search"} {
|
||||
var found Tool
|
||||
for _, tool := range BuiltinCatalog(root) {
|
||||
if tool.Name() == name {
|
||||
found = tool
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("tool %q not in catalog", name)
|
||||
}
|
||||
caps := CapabilitiesOf(found)
|
||||
if caps.Effect != EffectReadOnly {
|
||||
t.Errorf("%s effect = %v, want ReadOnly", name, caps.Effect)
|
||||
}
|
||||
// Current catalog intentionally keeps read tools non-thread-safe
|
||||
// until each is audited for shared mutable state (FileTracker, etc.).
|
||||
if caps.ThreadSafe {
|
||||
t.Errorf("%s ThreadSafe=true without explicit audit", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBuiltinCatalogRejectsUnknownRaw(t *testing.T) {
|
||||
// Prove the gate is not a rubber stamp: raw Unknown+ThreadSafe is rejected.
|
||||
problems := ValidateCapabilities("ghost", ToolCapabilities{Effect: EffectUnknown, ThreadSafe: true})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("expected ValidateCapabilities to reject Unknown+ThreadSafe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureArtifactIsWorkspaceWrite(t *testing.T) {
|
||||
for _, tool := range NewLocalControlArtifactTools(LocalControlArtifactOptions{}) {
|
||||
if CapabilitiesOf(tool).Effect != EffectWorkspaceWrite {
|
||||
t.Fatalf("%s effect = %v, want WorkspaceWrite", tool.Name(), CapabilitiesOf(tool).Effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesLookupRace(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
for _, tool := range BuiltinCatalog(t.TempDir()) {
|
||||
reg.Register(tool)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for _, tool := range reg.All() {
|
||||
_ = reg.Capabilities(tool.Name())
|
||||
_ = CapabilitiesOf(tool)
|
||||
caps := CapabilitiesOf(tool)
|
||||
if caps.ResourceKeys != nil {
|
||||
_ = caps.ResourceKeys(map[string]any{"path": "a/b.go", "url": "https://example.com"})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestEffectStringAndValid(t *testing.T) {
|
||||
for _, e := range []EffectClass{EffectUnknown, EffectReadOnly, EffectProcessRead, EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive} {
|
||||
if !e.Valid() {
|
||||
t.Fatalf("%v should be valid", e)
|
||||
}
|
||||
if e.String() == "invalid" || e.String() == "" {
|
||||
t.Fatalf("String for %d", e)
|
||||
}
|
||||
}
|
||||
if EffectClass(99).Valid() {
|
||||
t.Fatal("invalid class reported valid")
|
||||
}
|
||||
}
|
||||
|
||||
// deferredCapableTool models a deferred-eligible built-in that still carries
|
||||
// explicit capabilities (MCP tools do not implement CapabilityProvider and
|
||||
// stay Unknown by design).
|
||||
type deferredCapableTool struct {
|
||||
baseTool
|
||||
}
|
||||
|
||||
func (t deferredCapableTool) Deferred() bool { return true }
|
||||
|
||||
func (t deferredCapableTool) Run(context.Context, map[string]any) Result {
|
||||
return okResult("ok")
|
||||
}
|
||||
|
||||
func TestDeferredToolPreservesCapabilities(t *testing.T) {
|
||||
tool := deferredCapableTool{baseTool: baseTool{
|
||||
name: "deferred_reader",
|
||||
capabilities: ToolCapabilities{
|
||||
Effect: EffectReadOnly,
|
||||
ThreadSafe: false,
|
||||
ResourceKeys: fileResourceKeys,
|
||||
},
|
||||
}}
|
||||
if !IsDeferred(tool) {
|
||||
t.Fatal("expected deferred")
|
||||
}
|
||||
reg := NewRegistry()
|
||||
reg.Register(tool)
|
||||
got, ok := reg.Get("deferred_reader")
|
||||
if !ok {
|
||||
t.Fatal("missing registration")
|
||||
}
|
||||
caps := CapabilitiesOf(got)
|
||||
if caps.Effect != EffectReadOnly {
|
||||
t.Fatalf("effect = %v after deferred register", caps.Effect)
|
||||
}
|
||||
keys := caps.ResourceKeys(map[string]any{"path": "pkg/a.go"})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyFile+NormalizeResourcePath("pkg/a.go") {
|
||||
t.Fatalf("resource keys = %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAndMCPStyleDefaultUnknown(t *testing.T) {
|
||||
// Plugin and MCP adapters do not implement CapabilityProvider; they must
|
||||
// remain fail-closed (serialized) until they opt into a trusted contract.
|
||||
for _, name := range []string{"mcp_github_create_issue", "plugin_custom_tool"} {
|
||||
caps := CapabilitiesOf(plainTool{name: name})
|
||||
if caps.Effect != EffectUnknown || caps.ThreadSafe {
|
||||
t.Fatalf("%s = %+v, want Unknown/not-thread-safe", name, caps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemasUnchangedForCoreTools(t *testing.T) {
|
||||
// Metadata must not alter tool names or parameter shapes.
|
||||
root := t.TempDir()
|
||||
read := NewScopedReadFileTool(root, nil)
|
||||
if read.Name() != "read_file" {
|
||||
t.Fatalf("name = %q", read.Name())
|
||||
}
|
||||
params := read.Parameters()
|
||||
if params.Type != "object" {
|
||||
t.Fatalf("params type = %q", params.Type)
|
||||
}
|
||||
if _, ok := params.Properties["path"]; !ok {
|
||||
t.Fatal("path property missing")
|
||||
}
|
||||
// Safety path unchanged.
|
||||
if read.Safety().SideEffect != SideEffectRead {
|
||||
t.Fatalf("safety side effect changed: %v", read.Safety().SideEffect)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"path", "old_string", "new_string"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectWrite, "Edits files in place."),
|
||||
safety: promptSafety(SideEffectWrite, "Edits files in place."),
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -57,6 +57,8 @@ func newEscalateModelTool(registry modelregistry.Registry) escalateModelTool {
|
||||
Reason: "Requests a mid-run switch to a stronger model; the agent loop performs the switch.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Requests mid-run model switch; session-bound.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
@@ -465,6 +465,8 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
|
||||
// PTY/shell session — never concurrent.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
@@ -717,6 +719,8 @@ func NewWriteStdinTool(manager *execSessionManager) Tool {
|
||||
Reason: "Sending stdin can drive an existing shell process beyond the original command; empty polling and Ctrl-C interrupts are allowed automatically.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Writes to a retained process stdin — process interaction.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
|
||||
},
|
||||
manager: manager,
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"pattern"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
|
||||
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -53,7 +53,8 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"pattern"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
|
||||
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -33,7 +33,8 @@ func NewScopedListDirectoryTool(workspaceRoot string, scope PathScope) Tool {
|
||||
},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Lists directory entries without modifying files."),
|
||||
safety: readOnlySafety("Lists directory entries without modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -53,7 +53,8 @@ func newBrowserInstallTool(options localcontrol.BrowserOptions) Tool {
|
||||
},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Downloads browser runtime files for local browser automation."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Downloads browser runtime files for local browser automation."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -98,7 +99,8 @@ func newBrowserLaunchToolWithLauncher(options localcontrol.BrowserOptions, launc
|
||||
Required: []string{"app"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Launches a supported local Chromium/Electron app with DevTools enabled and attaches local browser automation."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Launches a supported local Chromium/Electron app with DevTools enabled and attaches local browser automation."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
launcher: launcher,
|
||||
@@ -172,7 +174,8 @@ func newBrowserConnectTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"target"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Attaches to a local browser or Electron app exposed over the Chrome DevTools Protocol."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Attaches to a local browser or Electron app exposed over the Chrome DevTools Protocol."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -211,7 +214,8 @@ func newBrowserOpenTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"url"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectNetwork, "Opens a URL through the local browser automation helper. For already-running Chrome/Electron apps, use browser_connect instead. If first use reports a missing browser runtime, run browser_install once."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectNetwork, "Opens a URL through the local browser automation helper. For already-running Chrome/Electron apps, use browser_connect instead. If first use reports a missing browser runtime, run browser_install once."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -253,7 +257,8 @@ func newBrowserSnapshotTool(options localcontrol.BrowserOptions) Tool {
|
||||
},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Reads the current local browser automation session state."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Reads the current local browser automation session state."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -292,7 +297,8 @@ func newBrowserClickTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"ref"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Clicks an element in the local browser automation session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Clicks an element in the local browser automation session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -332,7 +338,8 @@ func newBrowserTypeTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"ref", "text"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Types text into an element in the local browser automation session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Types text into an element in the local browser automation session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -371,7 +378,8 @@ func newBrowserPressTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"key"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Presses a key in the local browser automation session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Presses a key in the local browser automation session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -456,7 +464,8 @@ func newBrowserActionTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Interacts with the local browser automation session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Interacts with the local browser automation session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ func newCaptureArtifactTool(options LocalControlArtifactOptions) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(enabled, SideEffectLocalControl, "Captures local browser, desktop, or terminal state into the configured artifact directory."),
|
||||
// Captures write artifacts under the configured directory.
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options.Browser),
|
||||
desktop: localcontrol.NewDesktop(options.Desktop),
|
||||
|
||||
@@ -35,7 +35,8 @@ func newDesktopWindowsTool(options localcontrol.DesktopOptions) Tool {
|
||||
},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads native desktop window metadata."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads native desktop window metadata."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
desktop: localcontrol.NewDesktop(options),
|
||||
}
|
||||
@@ -81,7 +82,8 @@ func newDesktopSnapshotTool(options localcontrol.DesktopOptions) Tool {
|
||||
Required: []string{"pid", "window_id"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads a native desktop window state."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads a native desktop window state."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
desktop: localcontrol.NewDesktop(options),
|
||||
}
|
||||
@@ -125,7 +127,8 @@ func newDesktopActionTool(options localcontrol.DesktopOptions) Tool {
|
||||
Required: []string{"command", "input"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Interacts with native desktop windows."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Interacts with native desktop windows."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
desktop: localcontrol.NewDesktop(options),
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ func newTerminalSessionTool(options localcontrol.TerminalOptions) Tool {
|
||||
Required: []string{"action", "session"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalTerminal, "Launches or interacts with a local virtual terminal session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalTerminal, "Launches or interacts with a local virtual terminal session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
|
||||
},
|
||||
terminal: localcontrol.NewTerminal(options),
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ func NewScopedLSPNavigateTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"op", "path"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Queries the language server for code navigation; reads files, modifies nothing."),
|
||||
safety: readOnlySafety("Queries the language server for code navigation; reads files, modifies nothing."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: root,
|
||||
scope: scope,
|
||||
|
||||
@@ -39,6 +39,8 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads file contents without modifying files."),
|
||||
// ThreadSafe=false: may update FileTracker session state on read.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -33,7 +33,8 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"path"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads a minified view of file contents without modifying files."),
|
||||
safety: readOnlySafety("Reads a minified view of file contents without modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -34,6 +34,8 @@ func NewRequestPermissionsTool() Tool {
|
||||
Permission: PermissionAllow,
|
||||
Reason: "Requests a user permission decision but does not read, write, run commands, or use the network.",
|
||||
},
|
||||
// Interactive permission escalation.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Resource-key prefixes used for future conflict detection. Keys are pure
|
||||
// metadata in this PR — no locks or scheduling are applied.
|
||||
//
|
||||
// Unused prefixes (repository/pty/browser) are reserved for follow-up
|
||||
// classifiers so PR6 conflict keys stay vocabulary-stable.
|
||||
const (
|
||||
ResourceKeyFile = "file:"
|
||||
ResourceKeyDirectory = "directory:"
|
||||
ResourceKeyRepository = "repository:" // reserved
|
||||
ResourceKeyProcess = "process:"
|
||||
ResourceKeyPTY = "pty:" // reserved
|
||||
ResourceKeyBrowser = "browser:" // reserved
|
||||
ResourceKeyEndpoint = "endpoint:"
|
||||
ResourceKeySession = "session:"
|
||||
ResourceKeyWorkspace = "workspace:"
|
||||
)
|
||||
|
||||
// NormalizeResourcePath produces a deterministic path token for resource keys.
|
||||
// It never touches the filesystem (no EvalSymlinks / Stat) so it cannot cause
|
||||
// side effects or panic on missing paths. Empty input returns "".
|
||||
//
|
||||
// Rules:
|
||||
// - Trim space
|
||||
// - filepath.Clean
|
||||
// - Convert separators to '/'
|
||||
// - On Windows, lower-case for case-insensitive comparison
|
||||
// - Strip a leading "./"
|
||||
// - Never include credentials or query fragments (paths only)
|
||||
func NormalizeResourcePath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
// Reject URL-shaped values — those use endpoint: keys.
|
||||
if strings.Contains(path, "://") {
|
||||
return ""
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
if cleaned == "." {
|
||||
return "."
|
||||
}
|
||||
normalized := filepath.ToSlash(cleaned)
|
||||
normalized = strings.TrimPrefix(normalized, "./")
|
||||
if runtime.GOOS == "windows" {
|
||||
normalized = strings.ToLower(normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// fileResourceKeys extracts a file: resource key from common path argument names.
|
||||
// Missing or empty path returns nil (not an error); never panics.
|
||||
func fileResourceKeys(args map[string]any) []string {
|
||||
path := firstStringArg(args, "path", "file", "file_path", "filepath", "filename", "target")
|
||||
normalized := NormalizeResourcePath(path)
|
||||
if normalized == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{ResourceKeyFile + normalized}
|
||||
}
|
||||
|
||||
// directoryResourceKeys extracts a directory: key from path/directory/cwd args.
|
||||
func directoryResourceKeys(args map[string]any) []string {
|
||||
path := firstStringArg(args, "path", "directory", "dir", "cwd", "workdir")
|
||||
normalized := NormalizeResourcePath(path)
|
||||
if normalized == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{ResourceKeyDirectory + normalized}
|
||||
}
|
||||
|
||||
// endpointResourceKeys extracts an endpoint: key from url/endpoint args.
|
||||
// Host only is kept when a full URL is provided (no secrets/query/userinfo).
|
||||
func endpointResourceKeys(args map[string]any) []string {
|
||||
raw := firstStringArg(args, "url", "endpoint", "uri")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
host := resourceHost(raw)
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{ResourceKeyEndpoint + host}
|
||||
}
|
||||
|
||||
// sessionResourceKeys extracts a session: key from session / session_id args.
|
||||
// Numeric session ids (JSON numbers) are coerced to strings.
|
||||
func sessionResourceKeys(args map[string]any) []string {
|
||||
return scopedIDResourceKeys(ResourceKeySession, args, "session", "session_id", "sessionId")
|
||||
}
|
||||
|
||||
// processResourceKeys extracts process: keys for retained process / terminal
|
||||
// tools. Accepts session, session_id, pid, and numeric JSON values so
|
||||
// write_stdin (integer session_id) and terminal_session (string session)
|
||||
// both produce keys.
|
||||
func processResourceKeys(args map[string]any) []string {
|
||||
return scopedIDResourceKeys(ResourceKeyProcess, args,
|
||||
"session", "session_id", "sessionId", "process_id", "pid", "id")
|
||||
}
|
||||
|
||||
// scopedIDResourceKeys is the shared extract/trim/bound/prefix path for
|
||||
// session- and process-scoped resource keys.
|
||||
func scopedIDResourceKeys(prefix string, args map[string]any, keys ...string) []string {
|
||||
id := strings.TrimSpace(firstStringArg(args, keys...))
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
// Never put free-form content in keys — IDs only, bounded length.
|
||||
if len(id) > 128 {
|
||||
id = id[:128]
|
||||
}
|
||||
return []string{prefix + id}
|
||||
}
|
||||
|
||||
// workspaceResourceKeys returns a single workspace: root marker when the call
|
||||
// affects the whole workspace (e.g. glob without a path root).
|
||||
func workspaceResourceKeys(_ map[string]any) []string {
|
||||
return []string{ResourceKeyWorkspace + "root"}
|
||||
}
|
||||
|
||||
// multiFileResourceKeys collects path and paths[] arguments into file: keys.
|
||||
func multiFileResourceKeys(args map[string]any) []string {
|
||||
var keys []string
|
||||
keys = append(keys, fileResourceKeys(args)...)
|
||||
if raw, ok := args["paths"]; ok {
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
for _, p := range typed {
|
||||
if n := NormalizeResourcePath(p); n != "" {
|
||||
keys = append(keys, ResourceKeyFile+n)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if s, ok := item.(string); ok {
|
||||
if n := NormalizeResourcePath(s); n != "" {
|
||||
keys = append(keys, ResourceKeyFile+n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueKeys(keys)
|
||||
}
|
||||
|
||||
// applyPatchResourceKeys derives conflict keys for apply_patch: the cwd as a
|
||||
// directory key, optional path/paths args, plus any +++/--- file paths
|
||||
// parseable from the unified diff. Relative diff paths are joined under cwd
|
||||
// so the same file under different cwd values does not collide (e.g. cwd=pkg
|
||||
// + +++ b/new.go → file:pkg/new.go). Falls back to workspace:root when nothing
|
||||
// is available so a patch call is never "keyless" for the future conflict planner.
|
||||
func applyPatchResourceKeys(args map[string]any) []string {
|
||||
var keys []string
|
||||
keys = append(keys, directoryResourceKeys(args)...)
|
||||
keys = append(keys, multiFileResourceKeys(args)...)
|
||||
cwd := firstStringArg(args, "cwd", "workdir")
|
||||
patch := firstStringArg(args, "patch", "diff")
|
||||
for _, p := range pathsFromUnifiedDiff(patch) {
|
||||
joined := joinUnderResourceCwd(cwd, p)
|
||||
if n := NormalizeResourcePath(joined); n != "" {
|
||||
keys = append(keys, ResourceKeyFile+n)
|
||||
}
|
||||
}
|
||||
keys = uniqueKeys(keys)
|
||||
if len(keys) == 0 {
|
||||
return workspaceResourceKeys(args)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
|
||||
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
|
||||
// Never touches the filesystem.
|
||||
func joinUnderResourceCwd(cwd, rel string) string {
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
return ""
|
||||
}
|
||||
// Absolute (Unix or Windows) paths are already workspace-rooted or outside;
|
||||
// do not re-prefix.
|
||||
if filepath.IsAbs(rel) {
|
||||
return rel
|
||||
}
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" || cwd == "." {
|
||||
return rel
|
||||
}
|
||||
return filepath.ToSlash(filepath.Join(cwd, rel))
|
||||
}
|
||||
|
||||
// pathsFromUnifiedDiff extracts a/ and b/ file paths from unified-diff headers.
|
||||
// Malformed input returns nil; never panics.
|
||||
func pathsFromUnifiedDiff(patch string) []string {
|
||||
if strings.TrimSpace(patch) == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, line := range strings.Split(patch, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "--- "):
|
||||
rest := strings.TrimSpace(line[4:])
|
||||
// Drop trailing tab timestamp if present.
|
||||
if i := strings.IndexByte(rest, '\t'); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
if rest == "/dev/null" || rest == "" {
|
||||
continue
|
||||
}
|
||||
// Strip a/ or b/ prefix common in git diffs.
|
||||
if len(rest) > 2 && (rest[0] == 'a' || rest[0] == 'b') && rest[1] == '/' {
|
||||
rest = rest[2:]
|
||||
}
|
||||
out = append(out, rest)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstStringArg returns the first present argument as a string. Numeric JSON
|
||||
// values (float64/int from encoding/json) are coerced so tools that declare
|
||||
// integer session ids still yield resource keys.
|
||||
func firstStringArg(args map[string]any, keys ...string) string {
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case float64:
|
||||
// JSON numbers decode as float64. Prefer integer form when exact.
|
||||
if typed == float64(int64(typed)) {
|
||||
return fmt.Sprintf("%d", int64(typed))
|
||||
}
|
||||
return fmt.Sprintf("%v", typed)
|
||||
case float32:
|
||||
return fmt.Sprintf("%v", typed)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case jsonNumber:
|
||||
return typed.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonNumber matches encoding/json.Number without importing encoding/json here
|
||||
// for a one-line type assert (tests may inject it).
|
||||
type jsonNumber interface {
|
||||
String() string
|
||||
}
|
||||
|
||||
// resourceHost returns a lower-cased host for endpoint keys, stripping
|
||||
// userinfo, path, query, and fragment. Returns "" when not parseable safely.
|
||||
//
|
||||
// Order matters: path/query/fragment are removed BEFORE userinfo so a query
|
||||
// value containing "@" cannot steal the authority (e.g.
|
||||
// https://api.example.com/v1?token=@secret → endpoint:api.example.com).
|
||||
// host:port is preserved so two services on different ports do not collide.
|
||||
func resourceHost(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// Strip scheme.
|
||||
if idx := strings.Index(raw, "://"); idx >= 0 {
|
||||
raw = raw[idx+3:]
|
||||
}
|
||||
// Isolate authority: strip path/query/fragment first.
|
||||
if slash := strings.IndexAny(raw, "/?#"); slash >= 0 {
|
||||
raw = raw[:slash]
|
||||
}
|
||||
// Strip userinfo from the authority only.
|
||||
if at := strings.LastIndex(raw, "@"); at >= 0 {
|
||||
raw = raw[at+1:]
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.Contains(raw, "@") {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(raw)
|
||||
}
|
||||
|
||||
func uniqueKeys(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -48,7 +48,8 @@ func NewSkillTool(dir string) *skillTool {
|
||||
// unknown keys (AdditionalProperties:false).
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
|
||||
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ func NewToolSearchTool(registry *Registry) Tool {
|
||||
Reason: "Lists and loads already-registered tool schemas; performs no side effects.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Reads registry schemas only; no workspace mutation.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
|
||||
},
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
@@ -143,10 +143,11 @@ type PrePermissionRejecter interface {
|
||||
}
|
||||
|
||||
type baseTool struct {
|
||||
name string
|
||||
description string
|
||||
parameters Schema
|
||||
safety Safety
|
||||
name string
|
||||
description string
|
||||
parameters Schema
|
||||
safety Safety
|
||||
capabilities ToolCapabilities // zero value = EffectUnknown, not thread-safe
|
||||
}
|
||||
|
||||
func (tool baseTool) Name() string {
|
||||
|
||||
@@ -63,6 +63,8 @@ func NewUpdatePlanTool() *updatePlanTool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Updates in-memory planning state only."),
|
||||
// Session plan state — ordering matters across turns.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
|
||||
Reason: "Fetches remote URL content over the network.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Network read; ThreadSafe=false until client proven concurrent-safe.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: endpointResourceKeys},
|
||||
},
|
||||
client: client,
|
||||
resolver: resolver,
|
||||
|
||||
@@ -90,6 +90,8 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
|
||||
Reason: "Sends model-provided search query text to the configured web search backend.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Search backends may share non-thread-safe clients.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
|
||||
},
|
||||
backend: backend,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"path", "content"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
|
||||
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -94,6 +94,29 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix fingerprints are emitted after counters in insertion (turn)
|
||||
// order. The order is the order EmitPrefixHash was called, which is the
|
||||
// order the agent loop computed each turn's fingerprint, which is the
|
||||
// order a downstream consumer needs to correlate a prefix_hash event
|
||||
// with the cached_input_tokens counter for that turn. Sorting by
|
||||
// complete_prefix hash would destroy that correlation, so we do not
|
||||
// sort. The slice is already a deep copy from Finish (see
|
||||
// Recorder.Finish) so it is safe to range over without copying.
|
||||
for _, p := range t.PrefixHashes {
|
||||
if err := enc.Encode(map[string]any{
|
||||
"type": "prefix_hash",
|
||||
"base_instructions": p.BaseInstructionsHash,
|
||||
"confirmation_policy": p.ConfirmationPolicyHash,
|
||||
"project_context": p.ProjectContextHash,
|
||||
"skills": p.SkillsHash,
|
||||
"tools": p.ToolsHash,
|
||||
"schema": p.SchemaHash,
|
||||
"complete_prefix": p.CompletePrefixHash,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+30
-2
@@ -97,6 +97,25 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
}
|
||||
name, _ := obj["name"].(string)
|
||||
t.Counters = append(t.Counters, Counter{Name: name, Value: parseInt64(obj["value"])})
|
||||
case "prefix_hash":
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: not a valid NDJSON trace (no type:trace header)")
|
||||
}
|
||||
// Round-trip the seven prefix-hash fields. Missing fields are
|
||||
// accepted as empty strings (a partially-written trace from a
|
||||
// crashed emitter must not fatal a parse). The decoder tolerates
|
||||
// any shape the encoder produced and the seven keys are the
|
||||
// contract — adding a new field is non-breaking; renaming one
|
||||
// requires a schema version bump.
|
||||
t.PrefixHashes = append(t.PrefixHashes, PrefixHash{
|
||||
BaseInstructionsHash: stringField(obj, "base_instructions"),
|
||||
ConfirmationPolicyHash: stringField(obj, "confirmation_policy"),
|
||||
ProjectContextHash: stringField(obj, "project_context"),
|
||||
SkillsHash: stringField(obj, "skills"),
|
||||
ToolsHash: stringField(obj, "tools"),
|
||||
SchemaHash: stringField(obj, "schema"),
|
||||
CompletePrefixHash: stringField(obj, "complete_prefix"),
|
||||
})
|
||||
default:
|
||||
// Unknown event type: tolerate (forward-compat) but only after a
|
||||
// header has been seen.
|
||||
@@ -117,12 +136,21 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: non-empty input had no type:trace header")
|
||||
}
|
||||
if len(t.Spans) == 0 && len(t.Counters) == 0 {
|
||||
return nil, errors.New("parse trace: header present but no spans or counters recovered (corrupt or truncated)")
|
||||
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 {
|
||||
return nil, errors.New("parse trace: header present but no spans, counters, or prefix hashes recovered (corrupt or truncated)")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// stringField returns obj[key] as a string, or "" if the key is missing or
|
||||
// the value is not a string. JSON-marshaled trace events always emit
|
||||
// string fields as JSON strings, so the type assertion is the right
|
||||
// narrowing; a missing key produces an empty hash, which is what the
|
||||
// encoder would have produced for the absent value.
|
||||
func stringField(obj map[string]any, key string) string {
|
||||
s, _ := obj[key].(string)
|
||||
return s
|
||||
}
|
||||
func parseTime(v any) time.Time {
|
||||
s, _ := v.(string)
|
||||
if s == "" {
|
||||
|
||||
@@ -172,6 +172,22 @@ func (r *Recorder) StampFirstUsefulAction() {
|
||||
r.tr.FirstUsefulActionAt = time.Now()
|
||||
}
|
||||
|
||||
// EmitPrefixHash records one prompt-prefix fingerprint on the trace. Multiple
|
||||
// calls are allowed within a run (one per turn, typically) and accumulate in
|
||||
// order. The first call after Finish is a no-op; later calls are also no-ops
|
||||
// because the trace has been sealed.
|
||||
func (r *Recorder) EmitPrefixHash(p PrefixHash) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.finished {
|
||||
return
|
||||
}
|
||||
r.tr.PrefixHashes = append(r.tr.PrefixHashes, p)
|
||||
}
|
||||
|
||||
// Finish stamps CompletedAt, derives each span's parent (by interval
|
||||
// containment) and exclusive time, and returns a snapshot of the trace. Calling
|
||||
// Finish more than once returns the same snapshot.
|
||||
@@ -190,6 +206,7 @@ func (r *Recorder) Finish() *TurnTrace {
|
||||
// Copy the slices so callers cannot mutate the recorder's state.
|
||||
snap.Spans = append([]Span(nil), r.tr.Spans...)
|
||||
snap.Counters = append([]Counter(nil), r.tr.Counters...)
|
||||
snap.PrefixHashes = append([]PrefixHash(nil), r.tr.PrefixHashes...)
|
||||
return &snap
|
||||
}
|
||||
|
||||
|
||||
+47
-10
@@ -82,16 +82,52 @@ type Counter struct {
|
||||
// TurnTrace is the finished record for one agent.Run. It is the value
|
||||
// emitters serialize; it is not mutated after Finish returns a snapshot.
|
||||
type TurnTrace struct {
|
||||
SessionID string `json:"session_id"`
|
||||
RunID string `json:"run_id"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"`
|
||||
FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"`
|
||||
FirstTokenAt time.Time `json:"first_token_at,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Spans []Span `json:"spans"`
|
||||
Counters []Counter `json:"counters"`
|
||||
SessionID string `json:"session_id"`
|
||||
RunID string `json:"run_id"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"`
|
||||
FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"`
|
||||
FirstTokenAt time.Time `json:"first_token_at,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Spans []Span `json:"spans"`
|
||||
Counters []Counter `json:"counters"`
|
||||
PrefixHashes []PrefixHash `json:"prefix_hashes,omitempty"`
|
||||
}
|
||||
|
||||
// PrefixHash is one fingerprint of the prompt prefix emitted by an agent run.
|
||||
// The seven fields decompose the cacheable part of the request so a downstream
|
||||
// observer can detect which sub-component drifted turn-over-turn:
|
||||
// base_instructions (the embedded core system prompt), confirmation_policy,
|
||||
// project_context (AGENTS.md / ZERO.md chain), skills, tools, schema (tool
|
||||
// JSON schemas), and complete_prefix (SHA-256 of the canonical concatenation
|
||||
// of the other six). All hashes are hex-encoded SHA-256.
|
||||
//
|
||||
// Scope: the fingerprint covers 4 of 11 sections of buildSystemPrompt. The
|
||||
// seven sections NOT covered — modelPromptAddendum, sessionRuntimeContext,
|
||||
// approvedCommandPrefixContext, workspaceSeedContext, userGuidelines,
|
||||
// specialistDelegationContext, responseStyleContext — fire only for
|
||||
// non-default Options. For default Options (the common case), the four
|
||||
// captured substrings are the full prompt and the fingerprint is accurate.
|
||||
//
|
||||
// A run with a stable CompletePrefix across turns means the four captured
|
||||
// sub-components are byte-identical. A run where CompletePrefix changes
|
||||
// names the captured sub-component that drifted, but does NOT rule out
|
||||
// drift in the seven uncaptured sections. modelPromptAddendum in particular
|
||||
// changes on a model switch (a model_switches counter the trace already
|
||||
// emits), so a consumer correlating CompletePrefix stability with
|
||||
// cached_input_tokens must cross-check the model_switches counter to
|
||||
// disambiguate "no drift" from "drift in an uncaptured section." The fields
|
||||
// are emitted as a "prefix_hash" event in the NDJSON trace (see
|
||||
// WriteNDJSON).
|
||||
type PrefixHash struct {
|
||||
BaseInstructionsHash string `json:"base_instructions"`
|
||||
ConfirmationPolicyHash string `json:"confirmation_policy"`
|
||||
ProjectContextHash string `json:"project_context"`
|
||||
SkillsHash string `json:"skills"`
|
||||
ToolsHash string `json:"tools"`
|
||||
SchemaHash string `json:"schema"`
|
||||
CompletePrefixHash string `json:"complete_prefix"`
|
||||
}
|
||||
|
||||
// WallDuration is the total traced wall time of the run.
|
||||
@@ -229,5 +265,6 @@ func OptionalEventKeys() []string {
|
||||
"counter:" + CounterAcceptanceChecks,
|
||||
"counter:" + CounterPollingTurn,
|
||||
"counter:" + CounterModelSwitches,
|
||||
"event:prefix_hash",
|
||||
}
|
||||
}
|
||||
@@ -370,6 +370,27 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
r.Counter(CounterModelRequests, 3)
|
||||
r.Counter(CounterToolCalls, 7)
|
||||
r.StampFirstToken()
|
||||
// Two prefix_hash events so the round-trip test covers the third
|
||||
// event type. Insertion order is preserved by the parser (no sort
|
||||
// is applied on read or write).
|
||||
r.EmitPrefixHash(PrefixHash{
|
||||
BaseInstructionsHash: "b1",
|
||||
ConfirmationPolicyHash: "c1",
|
||||
ProjectContextHash: "p1",
|
||||
SkillsHash: "s1",
|
||||
ToolsHash: "t1",
|
||||
SchemaHash: "x1",
|
||||
CompletePrefixHash: "complete1",
|
||||
})
|
||||
r.EmitPrefixHash(PrefixHash{
|
||||
BaseInstructionsHash: "b2",
|
||||
ConfirmationPolicyHash: "c2",
|
||||
ProjectContextHash: "p2",
|
||||
SkillsHash: "s2",
|
||||
ToolsHash: "t2",
|
||||
SchemaHash: "x2",
|
||||
CompletePrefixHash: "complete2",
|
||||
})
|
||||
original := r.Finish()
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -398,6 +419,20 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
if parsed.FirstTokenAt.IsZero() {
|
||||
t.Fatal("first_token_at lost in round-trip")
|
||||
}
|
||||
// prefix_hash round-trip: two events, in insertion order, with all
|
||||
// seven sub-hash fields preserved exactly.
|
||||
if len(parsed.PrefixHashes) != 2 {
|
||||
t.Fatalf("expected 2 prefix_hash events after round-trip, got %d", len(parsed.PrefixHashes))
|
||||
}
|
||||
if parsed.PrefixHashes[0].CompletePrefixHash != "complete1" || parsed.PrefixHashes[1].CompletePrefixHash != "complete2" {
|
||||
t.Fatalf("prefix_hash insertion order lost: got %+v want [complete1, complete2]", parsed.PrefixHashes)
|
||||
}
|
||||
if parsed.PrefixHashes[0].BaseInstructionsHash != "b1" || parsed.PrefixHashes[0].SchemaHash != "x1" {
|
||||
t.Fatalf("prefix_hash sub-hashes lost on first event: got %+v", parsed.PrefixHashes[0])
|
||||
}
|
||||
if parsed.PrefixHashes[1].BaseInstructionsHash != "b2" || parsed.PrefixHashes[1].SchemaHash != "x2" {
|
||||
t.Fatalf("prefix_hash sub-hashes lost on second event: got %+v", parsed.PrefixHashes[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNDJSONRejectsNonTrace(t *testing.T) {
|
||||
@@ -411,10 +446,12 @@ func TestReadNDJSONRejectsNonTrace(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadNDJSONRejectsHeaderOnly(t *testing.T) {
|
||||
// A header with no recoverable spans/counters is corrupt/truncated, not empty.
|
||||
// A header with no recoverable spans, counters, or prefix hashes is
|
||||
// corrupt/truncated, not empty. prefix_hash is now a third valid
|
||||
// event type, so a header with only prefix_hash events is accepted.
|
||||
header := `{"type":"trace","name":"run","session_id":"s","run_id":"r"}` + "\n"
|
||||
if _, err := ReadNDJSON(strings.NewReader(header)); err == nil {
|
||||
t.Fatal("expected error for a trace header with no spans or counters")
|
||||
t.Fatal("expected error for a trace header with no spans, counters, or prefix hashes")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -63,6 +63,14 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
// do it once here, before any early return, so both the bracketed-paste
|
||||
// and right-click-paste paths are covered uniformly.
|
||||
m = m.disarmCancelConfirmation()
|
||||
// Same immediate-input transition as the keypress hook: text entering the
|
||||
// composer must land the caret in its solid-while-typing state right away
|
||||
// and refresh the typing timestamp. Without this, a paste right after the
|
||||
// blink phase hid the caret leaves the newly populated composer caret-less
|
||||
// until the next 530ms tick, and the stale timestamp makes that tick
|
||||
// toggle instead of going solid.
|
||||
m.lastCharTime = m.now()
|
||||
m.composerCursorVisible = true
|
||||
if content == "" {
|
||||
// Empty text clipboard — the user may have pasted a screenshot.
|
||||
// Probe the OS clipboard for image content asynchronously.
|
||||
|
||||
@@ -39,6 +39,8 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
{"Shift+Enter / Alt+Enter", "insert a newline (multi-line compose)"},
|
||||
{"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"Ctrl+X then letter", "common slash commands (m=/model, p=/provider, r=/resume, \u2026)"},
|
||||
{"Ctrl+X ?", "list every Ctrl+X slash shortcut"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
},
|
||||
},
|
||||
@@ -47,7 +49,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
bindings: []keybinding{
|
||||
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
|
||||
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
|
||||
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel"},
|
||||
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -55,6 +57,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
bindings: []keybinding{
|
||||
{"PgUp / PgDn", "scroll the transcript by a page"},
|
||||
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
|
||||
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
|
||||
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
|
||||
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
|
||||
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
|
||||
|
||||
@@ -79,6 +79,7 @@ func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) {
|
||||
"Ctrl+T", "cycle reasoning effort",
|
||||
"Shift+Tab", "Ctrl+P", "Ctrl+O",
|
||||
"drill into its sub-session",
|
||||
"Ctrl+X then letter", "/model",
|
||||
keybindingHelpFooter,
|
||||
} {
|
||||
if !strings.Contains(view, want) {
|
||||
@@ -224,7 +225,9 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
|
||||
m.height = 40
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
||||
m.keyBindings.toggleMouse = parseBinding("ctrl+m")
|
||||
m.keyBindings.toggleSidebar = parseBinding("ctrl+n")
|
||||
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
|
||||
// reaches configurable global bindings.
|
||||
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
|
||||
if !m.sidebarToggleAllowed() {
|
||||
t.Fatal("sidebar toggle should be allowed")
|
||||
}
|
||||
@@ -245,10 +248,10 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
|
||||
}
|
||||
|
||||
initialSidebar := next.sidebarHidden
|
||||
updated, _ = next.Update(testKeyCtrl('n'))
|
||||
updated, _ = next.Update(testKeyCtrl('y'))
|
||||
next = updated.(model)
|
||||
if next.sidebarHidden == initialSidebar {
|
||||
t.Fatal("remapped Ctrl+N toggleSidebar binding should still fire with a non-empty composer")
|
||||
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
|
||||
}
|
||||
if next.composerValue() != "hello" {
|
||||
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord
|
||||
// cancels itself. Short enough to not feel sticky; long enough to find the
|
||||
// second key without racing.
|
||||
const leaderTimeout = 2 * time.Second
|
||||
|
||||
// leaderCommandByKey maps the second key of a Ctrl+X chord to a builtin slash
|
||||
// command. Case-sensitive so m/M and p/P stay distinct. Only bare commands (no
|
||||
// args) — same as typing the slash and pressing Enter with an empty argument.
|
||||
var leaderCommandByKey = map[rune]string{
|
||||
'm': "/model",
|
||||
'p': "/provider",
|
||||
'P': "/plan",
|
||||
'M': "/stt-model",
|
||||
'v': "/voice",
|
||||
'c': "/clear",
|
||||
'C': "/context",
|
||||
's': "/stop",
|
||||
'i': "/image",
|
||||
'r': "/resume",
|
||||
'u': "/rewind",
|
||||
't': "/tools",
|
||||
'R': "/retry",
|
||||
}
|
||||
|
||||
// leaderExpiredMsg clears leader-pending when the follow-up window elapses.
|
||||
// seq must match m.leaderSeq or the tick is stale (a later Ctrl+X re-armed).
|
||||
type leaderExpiredMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
func (m model) canArmLeader() bool {
|
||||
return m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active && !m.helpOverlay && !m.leaderHelpOverlay
|
||||
}
|
||||
|
||||
func (m model) armLeader() (model, tea.Cmd) {
|
||||
m.leaderPending = true
|
||||
m.leaderSeq++
|
||||
seq := m.leaderSeq
|
||||
return m, tea.Tick(leaderTimeout, func(time.Time) tea.Msg {
|
||||
return leaderExpiredMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) clearLeader() model {
|
||||
m.leaderPending = false
|
||||
// Bump seq so an in-flight timeout tick becomes a no-op.
|
||||
m.leaderSeq++
|
||||
return m
|
||||
}
|
||||
|
||||
// handleLeaderKey consumes the key while leader-pending is armed. Never inserts
|
||||
// into the composer; either runs a mapped slash command, opens the chord map
|
||||
// (?), or cancels the chord. Ctrl+C is handled before this is called so
|
||||
// exit/cancel still works.
|
||||
func (m model) handleLeaderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Second Ctrl+X cancels (does not re-arm).
|
||||
if keyCtrl(msg, 'x') {
|
||||
return m.clearLeader(), nil
|
||||
}
|
||||
if keyIs(msg, tea.KeyEsc) {
|
||||
return m.clearLeader(), nil
|
||||
}
|
||||
// Ctrl+X ? opens the full leader-chord map (discoverability for every wired
|
||||
// slash shortcut). Not a letter binding, so handle before leaderSecondKey.
|
||||
if keyText(msg) == "?" && !keyAlt(msg) && !keyHasMod(msg, tea.ModCtrl) {
|
||||
m = m.clearLeader()
|
||||
m.leaderHelpOverlay = true
|
||||
return m, nil
|
||||
}
|
||||
key, ok := leaderSecondKey(msg)
|
||||
if !ok {
|
||||
return m.clearLeader(), nil
|
||||
}
|
||||
slash, mapped := leaderCommandByKey[key]
|
||||
if !mapped {
|
||||
return m.clearLeader(), nil
|
||||
}
|
||||
m = m.clearLeader()
|
||||
return m.executeSlash(slash)
|
||||
}
|
||||
|
||||
// leaderHelpBindings is the stable, display-ordered table of every wired
|
||||
// Ctrl+X chord. Keep in sync with leaderCommandByKey.
|
||||
func leaderHelpBindings() []keybinding {
|
||||
return []keybinding{
|
||||
{"Ctrl+X m", "open /model"},
|
||||
{"Ctrl+X p", "open /provider"},
|
||||
{"Ctrl+X P", "run /plan"},
|
||||
{"Ctrl+X M", "open /stt-model"},
|
||||
{"Ctrl+X v", "toggle /voice"},
|
||||
{"Ctrl+X c", "run /clear"},
|
||||
{"Ctrl+X C", "run /context"},
|
||||
{"Ctrl+X s", "run /stop"},
|
||||
{"Ctrl+X i", "run /image"},
|
||||
{"Ctrl+X r", "open /resume"},
|
||||
{"Ctrl+X u", "run /rewind"},
|
||||
{"Ctrl+X t", "run /tools"},
|
||||
{"Ctrl+X R", "run /retry"},
|
||||
{"Ctrl+X ?", "show this list"},
|
||||
}
|
||||
}
|
||||
|
||||
const leaderHelpFooter = "? or Esc to close \u00b7 letter after Ctrl+X runs the command"
|
||||
|
||||
// renderLeaderHelpLines builds the body of the Ctrl+X ? overlay — same shape as
|
||||
// renderKeybindingHelpLines (group title + key rows + footer) so the two modals
|
||||
// share layout and column alignment.
|
||||
func renderLeaderHelpLines(innerWidth int) []string {
|
||||
groups := []keybindingGroup{{
|
||||
title: "Slash commands",
|
||||
bindings: leaderHelpBindings(),
|
||||
}}
|
||||
keyColumn := keybindingKeyColumnWidth(groups)
|
||||
lines := make([]string, 0, 32)
|
||||
for index, group := range groups {
|
||||
if index > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, zeroTheme.accent.Render(group.title))
|
||||
for _, binding := range group.bindings {
|
||||
lines = append(lines, formatKeybindingLine(binding, keyColumn, innerWidth))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, zeroTheme.faint.Render(leaderHelpFooter))
|
||||
return lines
|
||||
}
|
||||
|
||||
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
|
||||
// general ? keyboard-shortcut overlay: same width helper, border style
|
||||
// (zeroTheme.line), panel fill, and centered block.
|
||||
func (m model) renderLeaderHelpOverlay(width int) string {
|
||||
overlayWidth := keybindingHelpOverlayWidth(width)
|
||||
lines := renderLeaderHelpLines(overlayWidth - 4)
|
||||
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
|
||||
return centerRenderedBlock(block, width)
|
||||
}
|
||||
|
||||
// leaderSecondKey extracts the letter for a leader follow-up. Prefers printable
|
||||
// text ("m" / "M"); falls back to code+Shift so test harnesses and terminals
|
||||
// that report only ModShift still work.
|
||||
func leaderSecondKey(msg tea.KeyMsg) (rune, bool) {
|
||||
if keyHasMod(msg, tea.ModCtrl) || keyAlt(msg) {
|
||||
return 0, false
|
||||
}
|
||||
text := keyText(msg)
|
||||
if runes := []rune(text); len(runes) == 1 && unicode.IsLetter(runes[0]) {
|
||||
return runes[0], true
|
||||
}
|
||||
code := keyCode(msg)
|
||||
if code >= 'a' && code <= 'z' {
|
||||
if keyShift(msg) {
|
||||
return code - ('a' - 'A'), true
|
||||
}
|
||||
return code, true
|
||||
}
|
||||
if code >= 'A' && code <= 'Z' {
|
||||
return code, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func TestLeaderArmsOnCtrlX(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, cmd := m.Update(testKeyCtrl('x'))
|
||||
next := updated.(model)
|
||||
if !next.leaderPending {
|
||||
t.Fatal("Ctrl+X should arm leader-pending")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("Ctrl+X must not type into the composer, got %q", next.composerValue())
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("arming leader should schedule a timeout tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderModelOpensPicker(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("m"))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("mapped second key should clear leader-pending")
|
||||
}
|
||||
if next.picker == nil || next.picker.kind != pickerModel {
|
||||
t.Fatalf("Ctrl+X m should open the model picker, picker=%#v", next.picker)
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("leader chord must not leave /model in the composer, got %q", next.composerValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderPreservesComposerDraft(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
})
|
||||
m = typeRunes(t, m, "draft text")
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("m"))
|
||||
next := updated.(model)
|
||||
if got := next.composerValue(); got != "draft text" {
|
||||
t.Fatalf("leader /model must preserve composer draft, got %q", got)
|
||||
}
|
||||
if next.picker == nil || next.picker.kind != pickerModel {
|
||||
t.Fatal("expected model picker after Ctrl+X m")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderTimeoutClears(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
if !m.leaderPending {
|
||||
t.Fatal("expected leader pending after Ctrl+X")
|
||||
}
|
||||
seq := m.leaderSeq
|
||||
updated, _ = m.Update(leaderExpiredMsg{seq: seq})
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("matching timeout tick should clear leader-pending")
|
||||
}
|
||||
// Stale tick must not clear a re-armed leader.
|
||||
updated, _ = next.Update(testKeyCtrl('x'))
|
||||
next = updated.(model)
|
||||
updated, _ = next.Update(leaderExpiredMsg{seq: seq})
|
||||
next = updated.(model)
|
||||
if !next.leaderPending {
|
||||
t.Fatal("stale timeout must not clear a re-armed leader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderEscCancels(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m = typeRunes(t, m, "keep me")
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("Esc should clear leader-pending")
|
||||
}
|
||||
if got := next.composerValue(); got != "keep me" {
|
||||
t.Fatalf("Esc cancel must not clear composer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderSecondCtrlXCancels(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyCtrl('x'))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("second Ctrl+X should cancel, not re-arm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderUnknownKeyClearsWithoutCommand(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
before := len(m.transcript)
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("z"))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("unknown second key should clear leader-pending")
|
||||
}
|
||||
if next.picker != nil {
|
||||
t.Fatal("unknown second key must not open a picker")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("unknown key must not type into composer, got %q", next.composerValue())
|
||||
}
|
||||
if len(next.transcript) != before {
|
||||
t.Fatalf("unknown key should not append transcript rows, before=%d after=%d", before, len(next.transcript))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderDoesNotInsertText(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("q"))
|
||||
next := updated.(model)
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("follow-up key must never appear in composer, got %q", next.composerValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderIgnoredWhenPickerOpen(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
})
|
||||
// Open model picker the normal way.
|
||||
m.input.SetValue("/model")
|
||||
updated, _ := m.handleSubmit()
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("setup: expected /model to open a picker")
|
||||
}
|
||||
updated, _ = m.Update(testKeyCtrl('x'))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("Ctrl+X must not arm leader while a picker is open")
|
||||
}
|
||||
// Printable 'm' should filter the picker, not dispatch another /model.
|
||||
updated, _ = next.Update(testKeyText("m"))
|
||||
next = updated.(model)
|
||||
if next.picker == nil {
|
||||
t.Fatal("picker should still be open")
|
||||
}
|
||||
if next.leaderPending {
|
||||
t.Fatal("picker keystrokes must not arm leader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderToolsRunsSlash(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyText("t"))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("expected leader cleared after Ctrl+X t")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Tools") && !transcriptContains(next.transcript, "tool") {
|
||||
// toolsText content varies; at least something was appended.
|
||||
if len(next.transcript) == 0 {
|
||||
t.Fatal("Ctrl+X t should run /tools and append output")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderHelpOverlayListsChords(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
groups := m.buildKeybindingGroups()
|
||||
var found bool
|
||||
for _, g := range groups {
|
||||
for _, b := range g.bindings {
|
||||
if strings.Contains(b.keys, "Ctrl+X") && strings.Contains(b.desc, "/model") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("buildKeybindingGroups must document Ctrl+X slash-command chords")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.width = 100
|
||||
m.height = 50
|
||||
m.altScreen = true
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
if !m.leaderPending {
|
||||
t.Fatal("setup: Ctrl+X should arm leader")
|
||||
}
|
||||
updated, _ = m.Update(testKeyText("?"))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("Ctrl+X ? should clear leader-pending")
|
||||
}
|
||||
if !next.leaderHelpOverlay {
|
||||
t.Fatal("Ctrl+X ? should open the leader chord map")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("? must not type into composer, got %q", next.composerValue())
|
||||
}
|
||||
view := plainRender(t, next.View())
|
||||
for _, want := range []string{
|
||||
"Ctrl+X Shortcuts",
|
||||
"Slash commands",
|
||||
"Ctrl+X m", "open /model",
|
||||
"Ctrl+X p", "open /provider",
|
||||
"Ctrl+X R", "run /retry",
|
||||
"╭", "╰", "│", // same box border chrome as the ? keyboard-shortcut modal
|
||||
} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("leader help missing %q, got:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
|
||||
// Both overlays must use the same frame builder (titled box + panel fill).
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.width = 100
|
||||
help := m.renderKeybindingHelpOverlay(100)
|
||||
leader := m.renderLeaderHelpOverlay(100)
|
||||
for _, block := range []struct {
|
||||
name string
|
||||
s string
|
||||
}{
|
||||
{"help", help},
|
||||
{"leader", leader},
|
||||
} {
|
||||
plain := ansiPattern.ReplaceAllString(block.s, "")
|
||||
for _, want := range []string{"╭", "╰", "│", "╮", "╯"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("%s overlay missing border %q:\n%s", block.name, want, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(ansiPattern.ReplaceAllString(help, ""), "Keyboard Shortcuts") {
|
||||
t.Fatal("help overlay should keep Keyboard Shortcuts title")
|
||||
}
|
||||
if !strings.Contains(ansiPattern.ReplaceAllString(leader, ""), "Ctrl+X Shortcuts") {
|
||||
t.Fatal("leader overlay should use Ctrl+X Shortcuts title in the border")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.leaderHelpOverlay = true
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
if next.leaderHelpOverlay {
|
||||
t.Fatal("Esc should close the leader help overlay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderHelpBindingsCoverEveryMapEntry(t *testing.T) {
|
||||
listed := map[string]bool{}
|
||||
for _, b := range leaderHelpBindings() {
|
||||
// "Ctrl+X m" → last field is the letter (skip the "?" meta row for map check).
|
||||
parts := strings.Fields(b.keys)
|
||||
if len(parts) != 2 || parts[0] != "Ctrl+X" {
|
||||
continue
|
||||
}
|
||||
letter := parts[1]
|
||||
if letter == "?" {
|
||||
continue
|
||||
}
|
||||
runes := []rune(letter)
|
||||
if len(runes) != 1 {
|
||||
t.Fatalf("unexpected key label %q", b.keys)
|
||||
}
|
||||
listed[string(runes[0])] = true
|
||||
if _, ok := leaderCommandByKey[runes[0]]; !ok {
|
||||
t.Fatalf("help lists %q but leaderCommandByKey has no entry", b.keys)
|
||||
}
|
||||
}
|
||||
for key := range leaderCommandByKey {
|
||||
if !listed[string(key)] {
|
||||
t.Fatalf("leaderCommandByKey has %q but help table omits it", string(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderSecondKeyShiftCapital(t *testing.T) {
|
||||
// Shift+p should map to /plan (capital P), not /provider.
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testKeyShift('p'))
|
||||
next := updated.(model)
|
||||
if next.leaderPending {
|
||||
t.Fatal("Shift+p should resolve the leader chord")
|
||||
}
|
||||
if next.picker != nil {
|
||||
t.Fatal("/plan should not open a picker")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Plan") && !transcriptContains(next.transcript, "plan") {
|
||||
// planText wording may vary; require some transcript growth from /plan.
|
||||
if len(next.transcript) == 0 {
|
||||
t.Fatal("Ctrl+X P should run /plan")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderSecondKeyFromTextCapital(t *testing.T) {
|
||||
key, ok := leaderSecondKey(testKeyText("M"))
|
||||
if !ok || key != 'M' {
|
||||
t.Fatalf("leaderSecondKey(M) = %q,%v, want M,true", key, ok)
|
||||
}
|
||||
key, ok = leaderSecondKey(testKeyText("m"))
|
||||
if !ok || key != 'm' {
|
||||
t.Fatalf("leaderSecondKey(m) = %q,%v, want m,true", key, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package tui
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
// moveModalSelection moves the highlight on an open selection surface by delta
|
||||
// (−1 previous / +1 next), matching arrow-key behavior for that surface.
|
||||
// handled is false when no list/selector modal owns the keys (caller may fall
|
||||
// through to plan toggle, composer, etc.).
|
||||
//
|
||||
// Surfaces: permission options, ask_user option lists, provider/MCP wizards and
|
||||
// managers, command pickers, slash/file autocomplete. Does not scroll the
|
||||
// transcript or drive composer history — those stay arrow-only.
|
||||
func (m model) moveModalSelection(delta int) (model, tea.Cmd, bool) {
|
||||
if delta == 0 {
|
||||
return m, nil, false
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(delta), nil, true
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
// moveAskUserCursor is a no-op in free-text mode (parity with arrows).
|
||||
return m.moveAskUserCursor(delta), nil, true
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
next, cmd := m.handleProviderWizardKey(syntheticArrowKey(delta))
|
||||
return next, cmd, true
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
next, cmd := m.handleMCPAddWizardKey(syntheticArrowKey(delta))
|
||||
return next, cmd, true
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
next, cmd := m.handleMCPManagerKey(syntheticArrowKey(delta))
|
||||
return next, cmd, true
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil, true
|
||||
}
|
||||
m.pickerMoved(delta)
|
||||
return m, nil, true
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(delta)
|
||||
return m, nil, true
|
||||
}
|
||||
return m, nil, false
|
||||
}
|
||||
|
||||
// syntheticArrowKey builds a KeyUp (delta < 0) or KeyDown (delta > 0) message so
|
||||
// wizard/manager handlers that only match arrow keys can share one code path
|
||||
// with Ctrl+P / Ctrl+N.
|
||||
func syntheticArrowKey(delta int) tea.KeyPressMsg {
|
||||
code := tea.KeyDown
|
||||
if delta < 0 {
|
||||
code = tea.KeyUp
|
||||
}
|
||||
return tea.KeyPressMsg(tea.Key{Code: code})
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func TestCtrlPNMovesModelPicker(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
Provider: &fakeProvider{},
|
||||
})
|
||||
m.input.SetValue("/model")
|
||||
updated, _ := m.handleSubmit()
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("setup: expected model picker")
|
||||
}
|
||||
if len(m.picker.items) < 2 {
|
||||
t.Skip("model picker has fewer than 2 items; cannot assert movement")
|
||||
}
|
||||
m.picker.selected = 0
|
||||
updated, _ = m.Update(testKey(tea.KeyDown))
|
||||
viaArrow := updated.(model).picker.selected
|
||||
m.picker.selected = 0
|
||||
updated, _ = m.Update(testKeyCtrl('n'))
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("picker should stay open")
|
||||
}
|
||||
if m.picker.selected != viaArrow {
|
||||
t.Fatalf("Ctrl+N selection = %d, KeyDown selection = %d", m.picker.selected, viaArrow)
|
||||
}
|
||||
updated, _ = m.Update(testKeyCtrl('p'))
|
||||
m = updated.(model)
|
||||
if m.picker.selected != 0 {
|
||||
t.Fatalf("Ctrl+P should return to 0, got %d", m.picker.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPNMovesSuggestions(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = typeRunes(t, m, "/s")
|
||||
if !m.suggestionsActive() || len(m.suggestions) < 2 {
|
||||
t.Fatalf("setup: need multiple suggestions, got %d", len(m.suggestions))
|
||||
}
|
||||
updated, _ := m.Update(testKeyCtrl('n'))
|
||||
m = updated.(model)
|
||||
if m.suggestionIdx != 1 {
|
||||
t.Fatalf("Ctrl+N should select index 1, got %d", m.suggestionIdx)
|
||||
}
|
||||
if got := m.composerValue(); got != "/s" {
|
||||
t.Fatalf("Ctrl+N must not insert text, composer = %q", got)
|
||||
}
|
||||
updated, _ = m.Update(testKeyCtrl('p'))
|
||||
m = updated.(model)
|
||||
if m.suggestionIdx != 0 {
|
||||
t.Fatalf("Ctrl+P should return to index 0, got %d", m.suggestionIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPNMovesPermissionOptions(t *testing.T) {
|
||||
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
|
||||
if m.pendingPermission == nil {
|
||||
t.Fatal("setup: expected permission prompt")
|
||||
}
|
||||
start := m.pendingPermission.cursor
|
||||
updated, _ := m.Update(testKeyCtrl('n'))
|
||||
m = updated.(model)
|
||||
if m.pendingPermission == nil {
|
||||
t.Fatal("permission prompt should stay open")
|
||||
}
|
||||
if m.pendingPermission.cursor == start {
|
||||
// Wrap or multi-option: try another N if single step no-op is impossible.
|
||||
if len(permissionOptions(m.pendingPermission.request)) > 1 {
|
||||
t.Fatalf("Ctrl+N should move permission cursor from %d", start)
|
||||
}
|
||||
}
|
||||
mid := m.pendingPermission.cursor
|
||||
updated, _ = m.Update(testKeyCtrl('p'))
|
||||
m = updated.(model)
|
||||
if m.pendingPermission.cursor != start && mid != start {
|
||||
if m.pendingPermission.cursor == mid {
|
||||
t.Fatalf("Ctrl+P should move permission cursor from %d", mid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.plan = planPanelState{
|
||||
steps: []planStep{{content: "step one", status: "pending"}},
|
||||
}
|
||||
if m.plan.isEmpty() {
|
||||
t.Fatal("setup: plan should be non-empty")
|
||||
}
|
||||
initial := m.plan.expanded
|
||||
updated, _ := m.Update(testKeyCtrl('p'))
|
||||
next := updated.(model)
|
||||
if next.plan.expanded == initial {
|
||||
t.Fatal("Ctrl+P with no modal should toggle plan.expanded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
Provider: &fakeProvider{},
|
||||
})
|
||||
m.plan = planPanelState{
|
||||
steps: []planStep{{content: "step one", status: "pending"}},
|
||||
expanded: true,
|
||||
}
|
||||
m.input.SetValue("/model")
|
||||
updated, _ := m.handleSubmit()
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("setup: expected picker")
|
||||
}
|
||||
updated, _ = m.Update(testKeyCtrl('p'))
|
||||
next := updated.(model)
|
||||
if !next.plan.expanded {
|
||||
t.Fatal("Ctrl+P in picker must not collapse the plan panel")
|
||||
}
|
||||
if next.picker == nil {
|
||||
t.Fatal("picker should remain open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlNNoOpWithoutModal(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.plan = planPanelState{
|
||||
steps: []planStep{{content: "step one", status: "pending"}},
|
||||
expanded: false,
|
||||
}
|
||||
before := m.plan.expanded
|
||||
updated, _ := m.Update(testKeyCtrl('n'))
|
||||
next := updated.(model)
|
||||
if next.picker != nil || next.suggestionsActive() {
|
||||
t.Fatal("Ctrl+N idle must not open a modal")
|
||||
}
|
||||
if next.plan.expanded != before {
|
||||
t.Fatal("Ctrl+N idle must not toggle the plan panel")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPNThemePickerPreview(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.input.SetValue("/theme")
|
||||
updated, _ := m.handleSubmit()
|
||||
m = updated.(model)
|
||||
if m.picker == nil || m.picker.kind != pickerTheme {
|
||||
t.Fatalf("setup: expected theme picker, got %#v", m.picker)
|
||||
}
|
||||
// Move with Ctrl+N — should not error; preview path is inside pickerMoved.
|
||||
start := m.picker.selected
|
||||
updated, _ = m.Update(testKeyCtrl('n'))
|
||||
m = updated.(model)
|
||||
if m.picker == nil {
|
||||
t.Fatal("theme picker should stay open")
|
||||
}
|
||||
if len(m.picker.items) > 1 && m.picker.selected == start {
|
||||
t.Fatalf("Ctrl+N should advance theme selection from %d", start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveModalSelectionMatchesArrowsOnSuggestions(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = typeRunes(t, m, "/")
|
||||
if len(m.suggestions) < 2 {
|
||||
t.Fatalf("need suggestions, got %d", len(m.suggestions))
|
||||
}
|
||||
m.suggestionIdx = 0
|
||||
viaArrow, _ := m.Update(testKey(tea.KeyDown))
|
||||
m.suggestionIdx = 0
|
||||
viaCtrl, _ := m.Update(testKeyCtrl('n'))
|
||||
if viaArrow.(model).suggestionIdx != viaCtrl.(model).suggestionIdx {
|
||||
t.Fatalf("arrow idx=%d ctrl idx=%d", viaArrow.(model).suggestionIdx, viaCtrl.(model).suggestionIdx)
|
||||
}
|
||||
}
|
||||
+180
-131
@@ -145,13 +145,19 @@ type model struct {
|
||||
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
|
||||
// /context can show a rolling average turn latency (the "is it slow?" signal).
|
||||
// Reset by /new.
|
||||
turnLatencySum time.Duration
|
||||
turnLatencyCount int
|
||||
turnTTFTSum time.Duration
|
||||
turnTTFTCount int
|
||||
transcript []transcriptRow
|
||||
transcriptDetailed bool
|
||||
helpOverlay bool // the `?` keyboard-shortcut overlay is open
|
||||
turnLatencySum time.Duration
|
||||
turnLatencyCount int
|
||||
turnTTFTSum time.Duration
|
||||
turnTTFTCount int
|
||||
transcript []transcriptRow
|
||||
transcriptDetailed bool
|
||||
helpOverlay bool // the `?` keyboard-shortcut overlay is open
|
||||
// leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord.
|
||||
leaderHelpOverlay bool
|
||||
// leaderPending is true after Ctrl+X until a second key, Esc, or timeout
|
||||
// resolves the chord (see leader.go). leaderSeq invalidates a stale tick.
|
||||
leaderPending bool
|
||||
leaderSeq int
|
||||
transcriptBodyHeights *transcriptBodyHeightCache
|
||||
input textinput.Model
|
||||
composer composerState
|
||||
@@ -199,8 +205,13 @@ type model struct {
|
||||
// lastKeyTime tracks every keypress timestamp for burst calculation.
|
||||
lastKeyTime time.Time
|
||||
// burstCount counts consecutive keypresses within 100ms (paste mode).
|
||||
burstCount int
|
||||
queuedMessage string
|
||||
burstCount int
|
||||
// terminalFocused tracks whether the terminal window currently has focus, per
|
||||
// tea.FocusMsg/tea.BlurMsg. Defaults to true since many terminals/multiplexers
|
||||
// never send focus events at all, and defaulting to "unfocused" would wrongly
|
||||
// hide the cursor for those users.
|
||||
terminalFocused bool
|
||||
queuedMessage string
|
||||
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
|
||||
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
|
||||
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
|
||||
@@ -770,6 +781,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
userCommands: loadedUserCommands,
|
||||
loadSkills: options.LoadSkills,
|
||||
composerCursorVisible: true,
|
||||
terminalFocused: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
projectConfigPath: options.ProjectConfigPath,
|
||||
@@ -881,6 +893,11 @@ const (
|
||||
// composerCursorBlinkInterval is the on/off period of the composer text cursor.
|
||||
const composerCursorBlinkInterval = 530 * time.Millisecond
|
||||
|
||||
// composerTypingIdleThreshold is how long a typing pause must last before the
|
||||
// cursor resumes blinking; comfortably above normal inter-keystroke gaps
|
||||
// (~150-300ms) so it won't flicker mid-sentence.
|
||||
const composerTypingIdleThreshold = 500 * time.Millisecond
|
||||
|
||||
// composerBlinkMsg toggles the composer cursor's visibility each tick. The custom
|
||||
// composer render draws its own cursor (not textinput's), so it drives its own
|
||||
// blink rather than relying on textinput.Blink.
|
||||
@@ -970,6 +987,9 @@ func (m model) noBlockingModal() bool {
|
||||
}
|
||||
|
||||
func (m model) quit() (tea.Model, tea.Cmd) {
|
||||
if m.providerWizard != nil {
|
||||
m.providerWizard.resetAimlapiOnboard()
|
||||
}
|
||||
m.stopPRWatcher()
|
||||
m.stopAllBackgroundTerminalSessions()
|
||||
m.shutdownLSPManager()
|
||||
@@ -1074,7 +1094,14 @@ func batchCommands(cmds ...tea.Cmd) tea.Cmd {
|
||||
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case composerBlinkMsg:
|
||||
m.composerCursorVisible = !m.composerCursorVisible
|
||||
switch {
|
||||
case !m.terminalFocused:
|
||||
m.composerCursorVisible = false // hidden while unfocused
|
||||
case m.now().Sub(m.lastCharTime) < composerTypingIdleThreshold:
|
||||
m.composerCursorVisible = true // solid while actively typing
|
||||
default:
|
||||
m.composerCursorVisible = !m.composerCursorVisible // idle + focused: blink as before
|
||||
}
|
||||
return m, composerBlinkCmd()
|
||||
case tea.BackgroundColorMsg:
|
||||
// Terminal background-color reply (from Init's RequestBackgroundColor). In
|
||||
@@ -1118,6 +1145,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.cancelConfirmActive = false
|
||||
}
|
||||
return m, nil
|
||||
case leaderExpiredMsg:
|
||||
if msg.seq == m.leaderSeq {
|
||||
m.leaderPending = false
|
||||
}
|
||||
return m, nil
|
||||
case dragEdgeScrollTickMsg:
|
||||
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
|
||||
return m, nil // stale, or the chain was stopped since this tick was scheduled
|
||||
@@ -1126,6 +1158,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
|
||||
case providerWizardOAuthMsg:
|
||||
return m.applyProviderWizardOAuth(msg)
|
||||
case aimlapiOnboardMsg:
|
||||
return m.applyAimlapiOnboard(msg)
|
||||
case aimlapiExistingBalanceMsg:
|
||||
return m.applyExistingAimlapiBalance(msg)
|
||||
case providerWizardDeviceCodeMsg:
|
||||
return m.applyProviderWizardDeviceCode(msg)
|
||||
case providerManagerCredsMsg:
|
||||
@@ -1203,6 +1239,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !keyIs(msg, tea.KeyEnter) {
|
||||
m.lastCharTime = now
|
||||
}
|
||||
// Enter the solid-while-typing state right away: only composerBlinkMsg
|
||||
// evaluates the typing threshold, so if the blink phase had just hidden
|
||||
// the caret, the typed character would render caret-less for up to a
|
||||
// full tick before the timer catches up.
|
||||
m.composerCursorVisible = true
|
||||
if m.setup.visible {
|
||||
return m.handleSetupKey(msg)
|
||||
}
|
||||
@@ -1233,9 +1274,44 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.burstCount = 0
|
||||
return m, nil
|
||||
}
|
||||
switch {
|
||||
case keyCtrl(msg, 'c'):
|
||||
// Ctrl+X ? leader-chord map: same dismiss keys as the general help overlay.
|
||||
if m.leaderHelpOverlay {
|
||||
if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') {
|
||||
m.leaderHelpOverlay = false
|
||||
}
|
||||
m.burstCount = 0
|
||||
return m, nil
|
||||
}
|
||||
if keyCtrl(msg, 'c') {
|
||||
if m.leaderPending {
|
||||
m = m.clearLeader()
|
||||
}
|
||||
return m.handleCtrlC()
|
||||
}
|
||||
if m.leaderPending {
|
||||
// Leader owns every keystroke until resolved; never type into the composer.
|
||||
return m.handleLeaderKey(msg)
|
||||
}
|
||||
if keyCtrl(msg, 'x') && m.canArmLeader() {
|
||||
return m.armLeader()
|
||||
}
|
||||
// Emacs Ctrl+P / Ctrl+N move selection in open menus. Runs before the
|
||||
// switch so menus win over global Ctrl+P (plan toggle). Idle Ctrl+P
|
||||
// falls through to that binding; idle Ctrl+N is a reserved no-op so it
|
||||
// never reaches remapped configurable bindings (e.g. toggleSidebar).
|
||||
if !m.transcriptDetailed && (keyCtrl(msg, 'p') || keyCtrl(msg, 'n')) {
|
||||
delta := 1
|
||||
if keyCtrl(msg, 'p') {
|
||||
delta = -1
|
||||
}
|
||||
if next, cmd, ok := m.moveModalSelection(delta); ok {
|
||||
return next, cmd
|
||||
}
|
||||
if keyCtrl(msg, 'n') {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }):
|
||||
return m.toggleDetailedTranscript(), nil
|
||||
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
|
||||
@@ -1497,6 +1573,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }):
|
||||
// Ctrl+P toggles the plan panel expansion (collapse/expand step list).
|
||||
// Modal selection is handled before this switch so menus win over toggle.
|
||||
if m.noBlockingModal() && !m.plan.isEmpty() {
|
||||
m.plan.expanded = !m.plan.expanded
|
||||
return m, nil
|
||||
@@ -1594,34 +1671,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(-1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
}
|
||||
// Suggestions keep Shift+arrows for the composer path (unchanged); plain
|
||||
// ↑/↓ and Ctrl+P/N still move the palette via moveModalSelection.
|
||||
if m.suggestionsActive() {
|
||||
break
|
||||
}
|
||||
if next, cmd, ok := m.moveModalSelection(-1); ok {
|
||||
return next, cmd
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle multiline navigation
|
||||
}
|
||||
@@ -1633,34 +1690,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
break
|
||||
}
|
||||
if next, cmd, ok := m.moveModalSelection(1); ok {
|
||||
return next, cmd
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle multiline navigation
|
||||
}
|
||||
@@ -1671,34 +1706,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-1), nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(1)
|
||||
return m, nil
|
||||
if next, cmd, ok := m.moveModalSelection(1); ok {
|
||||
return next, cmd
|
||||
}
|
||||
if next, ok := m.moveComposerVisualCursor(1); ok {
|
||||
return next, nil
|
||||
@@ -1717,34 +1726,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(1), nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(-1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
m.burstCount = 0
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(-1)
|
||||
return m, nil
|
||||
if next, cmd, ok := m.moveModalSelection(-1); ok {
|
||||
return next, cmd
|
||||
}
|
||||
if next, ok := m.moveComposerVisualCursor(-1); ok {
|
||||
return next, nil
|
||||
@@ -1870,11 +1853,19 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.recomputeSuggestions()
|
||||
return m, cmd
|
||||
case tea.FocusMsg:
|
||||
m.terminalFocused = true
|
||||
// Sync the caret with focus immediately: leaving it to the next
|
||||
// composerBlinkMsg tick can keep it hidden for up to a tick after the
|
||||
// terminal regains focus (and, on blur below, leave it visible in an
|
||||
// unfocused terminal for the same window).
|
||||
m.composerCursorVisible = true
|
||||
if m.notifier != nil {
|
||||
m.notifier.SetFocused(true)
|
||||
}
|
||||
return m, nil
|
||||
case tea.BlurMsg:
|
||||
m.terminalFocused = false
|
||||
m.composerCursorVisible = false
|
||||
if m.notifier != nil {
|
||||
m.notifier.SetFocused(false)
|
||||
}
|
||||
@@ -1952,11 +1943,21 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// advancing even when no run is pending, so the tick loop stays alive while
|
||||
// sidebarHasAgents() holds (and stops the moment the agents/sidebar clear).
|
||||
if !m.pending && !m.compactInFlight && !m.doctorInFlight {
|
||||
if m.sidebarHasAgents() && !m.reducedMotion {
|
||||
m.spinner, _ = m.spinner.Update(msg)
|
||||
// The tick also keeps advancing while the aimlapi.com onboarding sub-flow is
|
||||
// busy (its progress screen is spinner-only), even though no agent run is in
|
||||
// flight, so its shared MiniDot spinner keeps animating.
|
||||
//
|
||||
// Return the FPS-throttled tick that Update hands back — NOT m.spinner.Tick,
|
||||
// which fires immediately and busy-loops the frame at event-loop speed. That
|
||||
// makes the glyph spin far too fast (and burns CPU) on screens that sit here
|
||||
// for a while, e.g. the aimlapi checkout wait. The active-run path below
|
||||
// already does this; this keeps idle animation at the same cadence.
|
||||
if (m.sidebarHasAgents() || m.aimlapiOnboardAnimating()) && !m.reducedMotion {
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
m.spinnerPhase++
|
||||
m.spinnerTicking = true
|
||||
return m, m.spinner.Tick
|
||||
return m, cmd
|
||||
}
|
||||
m.spinnerTicking = false
|
||||
return m, nil
|
||||
@@ -2519,9 +2520,9 @@ func (m model) View() tea.View {
|
||||
var content string
|
||||
if m.setup.visible {
|
||||
content = m.setupView(chatWidth(m.width))
|
||||
} else if m.helpOverlay || !m.transcriptDetailed {
|
||||
// When helpOverlay is active the help panel is composited into the normal
|
||||
// transcript view as a true overlay (scrim + vertical centering), matching
|
||||
} else if m.helpOverlay || m.leaderHelpOverlay || !m.transcriptDetailed {
|
||||
// When helpOverlay / leaderHelpOverlay is active the panel is composited into
|
||||
// the normal transcript view as a true overlay (scrim + vertical centering), matching
|
||||
// how the suggestion picker / provider wizard / pickers are drawn.
|
||||
content = m.transcriptView()
|
||||
} else {
|
||||
@@ -2541,7 +2542,11 @@ func (m model) View() tea.View {
|
||||
if m.altScreen {
|
||||
view.BackgroundColor = zeroTheme.bgPanel
|
||||
}
|
||||
view.ReportFocus = m.notifier != nil
|
||||
// Always requested, independent of the notifier: the composer cursor's
|
||||
// focus/blink behavior (composerBlinkMsg above) needs tea.FocusMsg/BlurMsg
|
||||
// regardless of notification config. A standard, widely supported DEC
|
||||
// private mode (CSI ?1004h) that unsupported terminals silently ignore.
|
||||
view.ReportFocus = true
|
||||
// Voice mode's Space-hold gesture needs key-release events (Kitty protocol).
|
||||
// Request them only while voice mode is on — the renderer re-sends the request
|
||||
// only when the value changes, so gating this costs nothing (§10).
|
||||
@@ -2613,6 +2618,10 @@ func (m model) transcriptView() string {
|
||||
if m.helpOverlay {
|
||||
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
|
||||
}
|
||||
leaderHelpOverlayContent := ""
|
||||
if m.leaderHelpOverlay {
|
||||
leaderHelpOverlayContent = m.renderLeaderHelpOverlay(width)
|
||||
}
|
||||
|
||||
suggestionOverlay := m.suggestionOverlay(width)
|
||||
providerOverlay := m.providerWizardOverlay(width)
|
||||
@@ -2626,6 +2635,8 @@ func (m model) transcriptView() string {
|
||||
viewportOverlay = sttKeyOverlay
|
||||
case helpOverlayContent != "":
|
||||
viewportOverlay = helpOverlayContent
|
||||
case leaderHelpOverlayContent != "":
|
||||
viewportOverlay = leaderHelpOverlayContent
|
||||
case providerOverlay != "":
|
||||
viewportOverlay = providerOverlay
|
||||
case mcpAddOverlay != "":
|
||||
@@ -2770,6 +2781,11 @@ func (m model) footerView(width int) string {
|
||||
// full-screen transcript, or under any modal/overlay so it never competes for
|
||||
// attention. Width-tiered so a narrow terminal only shows the essential pointer.
|
||||
func (m model) composerIdleHint() string {
|
||||
// Leader-pending is always shown (even mid-type) so the user knows the next
|
||||
// key is a chord, not composer input.
|
||||
if m.leaderPending {
|
||||
return zeroTheme.faint.Render("Ctrl+X — await shortcut (m model · p provider · ? list · Esc cancel)")
|
||||
}
|
||||
// Managed (alt-screen) mode only: inline mode prints to native scrollback where
|
||||
// this footer row isn't a stable surface. Hidden while typing, during a run, in
|
||||
// the full-screen transcript, or under any modal/overlay.
|
||||
@@ -2788,9 +2804,9 @@ func (m model) composerIdleHint() string {
|
||||
case tierNarrow:
|
||||
hint = "? shortcuts"
|
||||
case tierMedium:
|
||||
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s copy", sidebarKey, mouseKey)
|
||||
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar", sidebarKey)
|
||||
default:
|
||||
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
|
||||
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
|
||||
}
|
||||
return zeroTheme.faint.Render(hint)
|
||||
}
|
||||
@@ -3475,7 +3491,7 @@ func (m model) composerLine(width int) string {
|
||||
}
|
||||
if argumentHint != "" {
|
||||
input.SetWidth(0)
|
||||
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint), width)
|
||||
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint, m.composerCursorVisible), width)
|
||||
}
|
||||
previews := validComposerPastePreviews(state, m.composerPastePreviews)
|
||||
displayState := composerDisplayStateForPastePreviews(state, previews)
|
||||
@@ -3751,16 +3767,23 @@ func composerCursorForVisualColumn(state composerState, segment composerVisualLi
|
||||
return segment.end
|
||||
}
|
||||
|
||||
func commandArgumentHintComposerLine(input textinput.Model, argumentHint string) string {
|
||||
func commandArgumentHintComposerLine(input textinput.Model, argumentHint string, cursorVisible bool) string {
|
||||
hintRunes := []rune(argumentHint)
|
||||
if len(hintRunes) == 0 {
|
||||
return input.View()
|
||||
}
|
||||
displayValue := strings.TrimRightFunc(input.Value(), unicode.IsSpace)
|
||||
// This alternate composer path must follow the same caret contract as
|
||||
// renderComposerInput: hidden while the terminal is unfocused and blinking
|
||||
// per composerCursorVisible, not a permanently painted cursor cell.
|
||||
cursor := zeroTheme.faint.Render(string(hintRunes[0]))
|
||||
if cursorVisible {
|
||||
cursor = composerCursor(cursor)
|
||||
}
|
||||
return zeroTheme.userPrompt.Render(input.Prompt) +
|
||||
zeroTheme.ink.Inline(true).Render(displayValue) +
|
||||
zeroTheme.faint.Render(" ") +
|
||||
composerCursor(zeroTheme.faint.Render(string(hintRunes[0]))) +
|
||||
cursor +
|
||||
zeroTheme.faint.Render(string(hintRunes[1:]))
|
||||
}
|
||||
|
||||
@@ -4069,6 +4092,12 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.chatScrollOffset = 0
|
||||
}
|
||||
|
||||
return m.dispatchCommand(command)
|
||||
}
|
||||
|
||||
// dispatchCommand runs a parsed slash/prompt command after submit/leader
|
||||
// preamble (history, composer clear, leave-prompt disarm) has already run.
|
||||
func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
switch command.kind {
|
||||
case commandEmpty:
|
||||
return m, nil
|
||||
@@ -4443,6 +4472,23 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// executeSlash runs a builtin slash command as if the user typed it and pressed
|
||||
// Enter, without clearing the composer draft. Used by Ctrl+X leader chords so a
|
||||
// mid-type prompt is preserved while /model (etc.) still opens.
|
||||
func (m model) executeSlash(input string) (tea.Model, tea.Cmd) {
|
||||
command := parseCommand(input)
|
||||
if command.kind == commandEmpty || command.kind == commandPrompt {
|
||||
return m, nil
|
||||
}
|
||||
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
}
|
||||
m.rememberInput(input)
|
||||
m.clearSuggestions()
|
||||
m.chatScrollOffset = 0
|
||||
return m.dispatchCommand(command)
|
||||
}
|
||||
|
||||
// launchPrompt starts a normal agent turn from text already accepted by the
|
||||
// composer. Queued prompts use this path too, so session and image behavior
|
||||
// stays identical to immediate submissions.
|
||||
@@ -4562,7 +4608,10 @@ func (m model) beginRun(cancel context.CancelFunc) model {
|
||||
// loop is already alive, when reduced motion is set, or when there is nothing to
|
||||
// animate, so an idle plain session schedules no timer.
|
||||
func (m *model) ensureSpinnerTick() tea.Cmd {
|
||||
if m.spinnerTicking || m.reducedMotion || !m.sidebarHasAgents() {
|
||||
if m.spinnerTicking || m.reducedMotion {
|
||||
return nil
|
||||
}
|
||||
if !m.sidebarHasAgents() && !m.aimlapiOnboardAnimating() {
|
||||
return nil
|
||||
}
|
||||
m.spinnerTicking = true
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
@@ -1964,6 +1965,35 @@ func TestCtrlCRequiresSecondPressToExit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmedCtrlCCancelsProviderAimlapiOnboarding(t *testing.T) {
|
||||
cancelled := false
|
||||
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
|
||||
m.providerWizard = &providerWizardState{
|
||||
step: providerWizardStepAimlapi,
|
||||
aimlapi: &aimlapiOnboardState{
|
||||
topupCancel: func() { cancelled = true },
|
||||
},
|
||||
}
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('c'))
|
||||
next := updated.(model)
|
||||
if cancelled {
|
||||
t.Fatal("first Ctrl+C should only arm exit confirmation")
|
||||
}
|
||||
|
||||
updated, cmd := next.Update(testKeyCtrl('c'))
|
||||
next = updated.(model)
|
||||
if !cancelled {
|
||||
t.Fatal("confirmed Ctrl+C did not cancel AIMLAPI onboarding")
|
||||
}
|
||||
if next.providerWizard.aimlapi != nil {
|
||||
t.Fatal("confirmed Ctrl+C retained AIMLAPI onboarding state")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("confirmed Ctrl+C should return quit command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlCExitConfirmationDisarmsOnInterveningKey(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
|
||||
|
||||
@@ -2647,6 +2677,172 @@ func TestModelNotifierFocusAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerBlinkStaysSolidWhileTyping(t *testing.T) {
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
now := base
|
||||
m := model{
|
||||
now: func() time.Time { return now },
|
||||
terminalFocused: true,
|
||||
lastCharTime: base,
|
||||
composerCursorVisible: true,
|
||||
}
|
||||
|
||||
// Each iteration simulates a keystroke (refreshing lastCharTime) followed by
|
||||
// a blink tick within the typing-idle threshold: cursor must stay solid
|
||||
// rather than toggling off, however many ticks land.
|
||||
for i := 0; i < 3; i++ {
|
||||
now = now.Add(200 * time.Millisecond)
|
||||
m.lastCharTime = now
|
||||
updated, _ := m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatalf("tick %d: expected cursor to stay visible while typing, got hidden", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerBlinkHiddenWhileUnfocused(t *testing.T) {
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
m := model{
|
||||
now: func() time.Time { return base },
|
||||
lastCharTime: base.Add(-time.Hour), // long idle, irrelevant while unfocused
|
||||
composerCursorVisible: true,
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.BlurMsg{})
|
||||
m = updated.(model)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
updated, _ = m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
if m.composerCursorVisible {
|
||||
t.Fatalf("tick %d: expected cursor to stay hidden while unfocused, got visible", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerBlinkResumesAfterRefocusAndIdle(t *testing.T) {
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
m := model{
|
||||
now: func() time.Time { return base },
|
||||
lastCharTime: base.Add(-time.Hour), // stale: well past the idle threshold
|
||||
composerCursorVisible: true,
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.BlurMsg{})
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.FocusMsg{})
|
||||
m = updated.(model)
|
||||
if !m.terminalFocused {
|
||||
t.Fatal("expected terminalFocused to be true after FocusMsg")
|
||||
}
|
||||
|
||||
updated, _ = m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
first := m.composerCursorVisible
|
||||
updated, _ = m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
second := m.composerCursorVisible
|
||||
if first == second {
|
||||
t.Fatalf("expected blink to toggle once idle+focused, got %v then %v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerBlinkTogglesWhenIdleAndFocused(t *testing.T) {
|
||||
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||
m := model{
|
||||
now: func() time.Time { return base },
|
||||
terminalFocused: true,
|
||||
lastCharTime: base.Add(-time.Hour),
|
||||
composerCursorVisible: true,
|
||||
}
|
||||
|
||||
updated, _ := m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
if m.composerCursorVisible {
|
||||
t.Fatal("expected cursor to toggle off on first idle+focused tick")
|
||||
}
|
||||
updated, _ = m.Update(composerBlinkMsg{})
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatal("expected cursor to toggle back on on second idle+focused tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerCursorShowsImmediatelyOnKeypress(t *testing.T) {
|
||||
// The blink phase may have just hidden the caret when the user starts
|
||||
// typing; the typed character must render with a caret immediately, not
|
||||
// after the next composerBlinkMsg tick evaluates the typing threshold.
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.terminalFocused = true
|
||||
m.composerCursorVisible = false
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'a', Text: "a"}))
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatal("expected caret visible immediately after a keypress, before any blink tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerCursorSyncsImmediatelyOnFocusChange(t *testing.T) {
|
||||
// Focus transitions must apply the caret contract synchronously: a blur
|
||||
// with no blink tick yet must not leave a visible caret in an unfocused
|
||||
// terminal, and a refocus must not leave the caret hidden for a tick.
|
||||
m := model{composerCursorVisible: true, terminalFocused: true}
|
||||
|
||||
updated, _ := m.Update(tea.BlurMsg{})
|
||||
m = updated.(model)
|
||||
if m.composerCursorVisible {
|
||||
t.Fatal("expected caret hidden immediately after BlurMsg, before any blink tick")
|
||||
}
|
||||
|
||||
updated, _ = m.Update(tea.FocusMsg{})
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatal("expected caret visible immediately after FocusMsg, before any blink tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerCursorShowsImmediatelyOnPaste(t *testing.T) {
|
||||
// A paste is the same immediate-input transition as a keypress: if the
|
||||
// blink phase had just hidden the caret, the freshly pasted composer must
|
||||
// render with a solid caret right away, and the typing timestamp must be
|
||||
// refreshed so the next blink tick holds solid instead of toggling off a
|
||||
// stale idle state.
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.terminalFocused = true
|
||||
m.composerCursorVisible = false
|
||||
m.lastCharTime = time.Now().Add(-time.Hour) // stale: well past the idle threshold
|
||||
|
||||
updated, _ := m.Update(tea.PasteMsg{Content: "pasted text"})
|
||||
m = updated.(model)
|
||||
if !m.composerCursorVisible {
|
||||
t.Fatal("expected caret visible immediately after a paste, before any blink tick")
|
||||
}
|
||||
if time.Since(m.lastCharTime) > time.Minute {
|
||||
t.Fatalf("expected the paste to refresh lastCharTime, still %v old", time.Since(m.lastCharTime))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandArgumentHintFollowsCursorVisibility(t *testing.T) {
|
||||
// The argument-hint composer line is an alternate render path that used to
|
||||
// paint its caret cell unconditionally, ignoring focus and blink state.
|
||||
input := textinput.New()
|
||||
input.SetValue("/rewind ")
|
||||
|
||||
visible := commandArgumentHintComposerLine(input, "hint", true)
|
||||
hidden := commandArgumentHintComposerLine(input, "hint", false)
|
||||
if visible == hidden {
|
||||
t.Fatal("expected the rendered hint line to differ between visible and hidden caret states")
|
||||
}
|
||||
if want := composerCursor(zeroTheme.faint.Render("h")); !strings.Contains(visible, want) {
|
||||
t.Fatalf("expected visible-caret render to contain the styled cursor cell, got %q", visible)
|
||||
}
|
||||
if got := hidden; strings.Contains(got, composerCursor(zeroTheme.faint.Render("h"))) {
|
||||
t.Fatalf("expected hidden-caret render to drop the styled cursor cell, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrimViewportLine(t *testing.T) {
|
||||
// Blank lines are left untouched (no scrim).
|
||||
if got := scrimViewportLine(" ", 10); got != " " {
|
||||
|
||||
+142
-5
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/browser"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
@@ -29,6 +31,9 @@ const (
|
||||
setupStageEndpoint
|
||||
setupStageName
|
||||
setupStageCredentials
|
||||
// aimlapi.com onboarding sub-flow, reached when the selected
|
||||
// provider is aimlapi. Delegated to the shared aimlapiOnboardState.
|
||||
setupStageAimlapi
|
||||
setupStageModel
|
||||
setupStageSafety
|
||||
setupStageReady
|
||||
@@ -62,6 +67,25 @@ type setupState struct {
|
||||
modelErr string
|
||||
modelSrc string
|
||||
modelGen uint64
|
||||
// aimlapi holds the shared aimlapi.com onboarding sub-flow while
|
||||
// setup is on setupStageAimlapi.
|
||||
aimlapi *aimlapiOnboardState
|
||||
}
|
||||
|
||||
// aimlapiTopupEvent is one live update from a running partner checkout, streamed
|
||||
// over a channel so the TUI can show progress (and the direct checkout link) as
|
||||
// it happens instead of blocking on a single result. Mirrors openclaude's
|
||||
// onStatus-driven progress screen.
|
||||
type aimlapiTopupEvent struct {
|
||||
detail string // for opening-checkout this is the direct checkout URL
|
||||
done bool
|
||||
result aimlapi.ProvisionedKey
|
||||
err error
|
||||
|
||||
// session carries the live partner-checkout session token (empty string means
|
||||
// "drop the retained token"); hasSession distinguishes it from a status update.
|
||||
session string
|
||||
hasSession bool
|
||||
}
|
||||
|
||||
// setupOAuthMsg carries the result of a first-run browser OAuth login.
|
||||
@@ -137,6 +161,16 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// The aimlapi.com sub-flow owns all of its keys; Ctrl+C still quits.
|
||||
if m.setup.stage == setupStageAimlapi {
|
||||
if keyCtrl(msg, 'c') {
|
||||
if m.setup.aimlapi != nil {
|
||||
m.setup.aimlapi.cancelStream()
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m.handleSetupAimlapiKey(msg)
|
||||
}
|
||||
if m.setupEndpointInputActive() {
|
||||
return m.handleSetupEndpointKey(msg)
|
||||
}
|
||||
@@ -183,7 +217,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.advanceSetup()
|
||||
}
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyUp):
|
||||
case keyIs(msg, tea.KeyUp) || keyCtrl(msg, 'p'):
|
||||
if m.setup.stage == setupStageMethod {
|
||||
m.moveSetupMethod(-1)
|
||||
} else if m.setup.stage == setupStageProvider {
|
||||
@@ -192,7 +226,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.moveSetupModel(-1)
|
||||
}
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyDown):
|
||||
case keyIs(msg, tea.KeyDown) || keyCtrl(msg, 'n'):
|
||||
if m.setup.stage == setupStageMethod {
|
||||
m.moveSetupMethod(1)
|
||||
} else if m.setup.stage == setupStageProvider {
|
||||
@@ -269,6 +303,10 @@ func (m model) handleSetupPaste(msg tea.PasteMsg) (tea.Model, tea.Cmd) {
|
||||
m.appendSetupBaseURL([]rune(msg.Content))
|
||||
case m.setupNameInputActive():
|
||||
m.appendSetupName([]rune(msg.Content))
|
||||
case m.setup.stage == setupStageAimlapi:
|
||||
if m.setup.aimlapi != nil {
|
||||
m.setup.aimlapi.appendInput(msg.Content)
|
||||
}
|
||||
case m.setupCredentialInputActive():
|
||||
previousAPIKey := m.setup.apiKey.Value()
|
||||
var cmd tea.Cmd
|
||||
@@ -421,10 +459,21 @@ func setupContentTop(height int, contentLines int, hasError bool) int {
|
||||
func (m model) setupStages() []setupStage {
|
||||
stages := []setupStage{setupStageWelcome, setupStageMethod}
|
||||
if m.setup.oauthMode {
|
||||
// OAuth path skips endpoint/name/credentials (the login provides the credential).
|
||||
// OAuth path skips endpoint/name/credentials because login provides the credential.
|
||||
return append(stages, setupStageProvider, setupStageModel, setupStageSafety, setupStageReady)
|
||||
}
|
||||
stages = append(stages, setupStageProvider)
|
||||
if setupProviderIsAimlapi(m.setupProvider()) {
|
||||
// Preserve the catalog's normal env-credential path in first-run setup.
|
||||
// A ready AIMLAPI_API_KEY needs no guided identity flow: model discovery
|
||||
// resolves it at runtime and saveSetupProvider persists APIKeyEnv, not the
|
||||
// materialized secret. /provider owns the richer balance/top-up preflight.
|
||||
if setupAimlapiEnvKey() != "" {
|
||||
return append(stages, setupStageModel, setupStageSafety, setupStageReady)
|
||||
}
|
||||
stages = append(stages, setupStageAimlapi, setupStageModel, setupStageSafety, setupStageReady)
|
||||
return stages
|
||||
}
|
||||
if setupProviderNeedsEndpoint(m.setupProvider()) {
|
||||
stages = append(stages, setupStageEndpoint, setupStageName)
|
||||
}
|
||||
@@ -432,6 +481,14 @@ func (m model) setupStages() []setupStage {
|
||||
return stages
|
||||
}
|
||||
|
||||
func setupAimlapiEnvKey() string {
|
||||
return strings.TrimSpace(os.Getenv("AIMLAPI_API_KEY"))
|
||||
}
|
||||
|
||||
func setupProviderIsAimlapi(option SetupProviderOption) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(option.ID), "aimlapi")
|
||||
}
|
||||
|
||||
// setupReturnToMethod resets the OAuth selection when navigating back to the
|
||||
// connect-method chooser.
|
||||
func (m model) setupReturnToMethod() model {
|
||||
@@ -607,6 +664,54 @@ func (m model) applySetupOAuth(msg setupOAuthMsg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// handleSetupAimlapiKey delegates a key press to first-run setup's aimlapi.com
|
||||
// sub-flow and reacts to its outcome.
|
||||
func (m model) handleSetupAimlapiKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.setup.aimlapi == nil {
|
||||
return m, nil
|
||||
}
|
||||
cmd, outcome := m.setup.aimlapi.handleKey(msg)
|
||||
return m.resolveSetupAimlapi(cmd, outcome)
|
||||
}
|
||||
|
||||
// applySetupAimlapiOnboard routes an async aimlapi.com onboarding result into
|
||||
// first-run setup's sub-flow and reacts to its outcome.
|
||||
func (m model) applySetupAimlapiOnboard(msg aimlapiOnboardMsg) (tea.Model, tea.Cmd) {
|
||||
if m.setup.aimlapi == nil || m.setup.aimlapi != msg.state {
|
||||
return m, nil
|
||||
}
|
||||
cmd, outcome := m.setup.aimlapi.apply(msg)
|
||||
return m.resolveSetupAimlapi(cmd, outcome)
|
||||
}
|
||||
|
||||
// resolveSetupAimlapi lands the sub-flow outcome on first-run setup: a terminal
|
||||
// key moves the issued key into the setup and jumps to model selection; a cancel
|
||||
// backs out to the provider list.
|
||||
func (m model) resolveSetupAimlapi(cmd tea.Cmd, outcome aimlapiOutcome) (tea.Model, tea.Cmd) {
|
||||
switch outcome {
|
||||
case aimlapiDone:
|
||||
m.setup.apiKey.SetValue(m.setup.aimlapi.apiKey)
|
||||
m.setup.baseURL = m.setup.aimlapi.baseURL
|
||||
m.setup.aimlapi = nil
|
||||
m.setup.err = ""
|
||||
m.setup.stage = setupStageModel
|
||||
m.resetSetupModels()
|
||||
m.setup.modelErr = ""
|
||||
m.setup.modelGen++
|
||||
discoverCmd := m.setupModelDiscoveryCmd(m.setup.modelGen)
|
||||
m.setup.modelLoad = discoverCmd != nil
|
||||
return m, discoverCmd
|
||||
case aimlapiCancel:
|
||||
m.setup.aimlapi = nil
|
||||
m.setup.err = ""
|
||||
m.setup.stage = setupStageProvider
|
||||
return m, nil
|
||||
}
|
||||
// Keep the shared spinner tick alive whenever the sub-flow just entered a busy
|
||||
// or progress state, so its animated spinner advances during onboarding.
|
||||
return m, tea.Batch(cmd, m.ensureSpinnerTick())
|
||||
}
|
||||
|
||||
func (m model) nextSetupStage() setupStage {
|
||||
stages := m.setupStages()
|
||||
for index, stage := range stages {
|
||||
@@ -621,6 +726,14 @@ func (m model) nextSetupStage() setupStage {
|
||||
}
|
||||
|
||||
func (m model) previousSetupStage() setupStage {
|
||||
// The aimlapi.com connect flow is terminal: once it has issued a key, its
|
||||
// transient state is cleared. Going back from model selection or Safety can
|
||||
// otherwise strand the user on an empty intermediate screen. Return to the
|
||||
// provider list so choosing aimlapi.com again starts a fresh connect flow.
|
||||
if (m.setup.stage == setupStageModel || m.setup.stage == setupStageSafety) &&
|
||||
setupProviderIsAimlapi(m.setupProvider()) {
|
||||
return setupStageProvider
|
||||
}
|
||||
stages := m.setupStages()
|
||||
for index, stage := range stages {
|
||||
if stage == m.setup.stage {
|
||||
@@ -699,6 +812,9 @@ func (m model) advanceSetup() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.setup.stage = m.nextSetupStage()
|
||||
m.setup.err = ""
|
||||
if m.setup.stage == setupStageAimlapi && m.setup.aimlapi == nil {
|
||||
m.setup.aimlapi = newAimlapiOnboard(browser.OpenURL)
|
||||
}
|
||||
if m.setup.stage == setupStageModel {
|
||||
m.resetSetupModels()
|
||||
m.setup.modelErr = ""
|
||||
@@ -835,7 +951,7 @@ func (m model) setupModelDiscoveryCmd(gen uint64) tea.Cmd {
|
||||
apiKey = resolved
|
||||
}
|
||||
}
|
||||
profile := providerWizardDiscoveryProfile(provider, apiKey)
|
||||
profile := providerWizardDiscoveryProfile(provider, apiKey, m.setupBaseURL(option))
|
||||
secrets := append(append([]string{}, baseSecrets...), apiKey, profile.APIKey)
|
||||
models, err := discover(ctx, profile)
|
||||
return setupModelsDiscoveredMsg{providerID: providerID, gen: gen, redactionSecrets: secrets, models: models, err: err}
|
||||
@@ -1099,6 +1215,16 @@ func (m model) setupCredentialAPIKey(option SetupProviderOption) string {
|
||||
}
|
||||
|
||||
func (m model) setupBaseURL(option SetupProviderOption) string {
|
||||
// aimlapi.com never shows the endpoint field, but its onboarding resolves the
|
||||
// inference endpoint (honoring an AIMLAPI_INFERENCE_URL override) onto
|
||||
// m.setup.baseURL. Persist that so an override reaches the saved profile instead
|
||||
// of being replaced by the catalog default at save time.
|
||||
if providerWizardIsAimlapi(setupProviderDescriptor(option)) {
|
||||
if baseURL := strings.TrimSpace(m.setup.baseURL); baseURL != "" {
|
||||
return baseURL
|
||||
}
|
||||
return strings.TrimSpace(aimlapi.ResolveEndpoints().InferenceBaseURL)
|
||||
}
|
||||
if !setupProviderNeedsEndpoint(option) {
|
||||
return ""
|
||||
}
|
||||
@@ -1226,6 +1352,11 @@ func (m model) setupStageLines(width int, height int) []string {
|
||||
return m.setupNameLines(width)
|
||||
case setupStageCredentials:
|
||||
return m.setupCredentialLines(width)
|
||||
case setupStageAimlapi:
|
||||
if m.setup.aimlapi != nil {
|
||||
return m.setup.aimlapi.view(width, m.spinnerGlyph())
|
||||
}
|
||||
return nil
|
||||
case setupStageModel:
|
||||
return m.setupModelLines(width, height)
|
||||
case setupStageSafety:
|
||||
@@ -1606,7 +1737,11 @@ func setupProviderBlockWidth(terminalWidth int, providers []SetupProviderOption)
|
||||
available := maxInt(24, minInt(terminalWidth-8, 44))
|
||||
target := maxInt(lipgloss.Width(" 2/6"), lipgloss.Width(" Choose a provider"))
|
||||
for _, provider := range providers {
|
||||
target = maxInt(target, 2+lipgloss.Width(displayValue(provider.Name, provider.ID)))
|
||||
label := displayValue(provider.Name, provider.ID)
|
||||
if provider.Recommended {
|
||||
label = "★ " + label + " (recommended)"
|
||||
}
|
||||
target = maxInt(target, 2+lipgloss.Width(label))
|
||||
}
|
||||
target = maxInt(target, 32)
|
||||
return minInt(target, available)
|
||||
@@ -1694,6 +1829,8 @@ func (m model) setupFooter() string {
|
||||
return zeroTheme.faint.Render("paste key optional ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue left back")
|
||||
}
|
||||
return zeroTheme.accent.Render("Space") + zeroTheme.faint.Render(" to continue")
|
||||
case setupStageAimlapi:
|
||||
return zeroTheme.faint.Render("type/↑↓ choose ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue left back Esc cancel")
|
||||
case setupStageProvider:
|
||||
if m.setup.oauthMode && m.setupProviderDescriptor().OAuthDeviceFlow {
|
||||
return zeroTheme.faint.Render("↑/↓ choose ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" sign in ") + zeroTheme.accent.Render("d") + zeroTheme.faint.Render(" device code q quit")
|
||||
|
||||
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
@@ -49,6 +51,611 @@ func TestSetupMethodOptionsDropsOAuthWithoutOAuthProviders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiCheckoutLinkWrapsWithoutTruncation(t *testing.T) {
|
||||
link := "https://checkout.stripe.com/c/pay/cs_test_abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789#fidkdWxOYHwnPyd1blpxYHZxWjA0"
|
||||
lines := aimlapiLinkLines(link, 32)
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected long checkout link to wrap, got %d line(s): %#v", len(lines), lines)
|
||||
}
|
||||
plain := plainRender(t, strings.Join(lines, "\n"))
|
||||
for index, line := range strings.Split(plain, "\n") {
|
||||
if strings.Contains(line, "://") {
|
||||
t.Fatalf("line %d still exposes an auto-detectable URL scheme: %q", index, line)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, "…") {
|
||||
t.Fatalf("wrapped checkout link should not be truncated:\n%s", plain)
|
||||
}
|
||||
var joined strings.Builder
|
||||
for _, line := range strings.Split(plain, "\n") {
|
||||
joined.WriteString(strings.TrimSpace(line))
|
||||
}
|
||||
if got := joined.String(); got != link {
|
||||
t.Fatalf("wrapped checkout link changed:\ngot %q\nwant %q", got, link)
|
||||
}
|
||||
for index, line := range strings.Split(plain, "\n") {
|
||||
if got := lipgloss.Width(line); got > 32 {
|
||||
t.Fatalf("line %d width = %d, want <= 32: %q", index, got, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiCheckoutLinkReplacesProgressSpinner(t *testing.T) {
|
||||
link := "https://checkout.stripe.com/c/pay/cs_test_abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
detail: link,
|
||||
}
|
||||
view := plainRender(t, strings.Join(state.viewProgress(40, "spin"), "\n"))
|
||||
assertContains(t, view, "Opening checkout in browser...")
|
||||
assertContains(t, view, "https:")
|
||||
assertContains(t, view, "//checkout.stripe.com")
|
||||
assertNotContains(t, view, "spin")
|
||||
}
|
||||
|
||||
func TestAimlapiEmailInputRejectsIncompleteDomain(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepEmailInput,
|
||||
email: "123@123.",
|
||||
}
|
||||
|
||||
cmd, outcome := state.handleKey(testKey(tea.KeyEnter))
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("invalid email should not start an account request")
|
||||
}
|
||||
if outcome != aimlapiContinue || state.busy {
|
||||
t.Fatalf("invalid email returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
|
||||
}
|
||||
if state.errText != aimlapi.MsgEmailInvalid {
|
||||
t.Fatalf("error = %q, want %q", state.errText, aimlapi.MsgEmailInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiEverythingReadyBackReturnsToProviderList(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{
|
||||
{ID: "openai", Name: "OpenAI"},
|
||||
{ID: "aimlapi", Name: "aimlapi.com"},
|
||||
},
|
||||
}})
|
||||
m.setup.selected = 1
|
||||
m.setup.stage = setupStageAimlapi
|
||||
m.setup.aimlapi = &aimlapiOnboardState{
|
||||
step: aimlapiStepDone,
|
||||
successLines: []string{aimlapi.MsgEverythingRuns},
|
||||
}
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyLeft))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("back from Everything is ready should not start a command")
|
||||
}
|
||||
if next.setup.stage != setupStageProvider {
|
||||
t.Fatalf("stage = %v, want provider list", next.setup.stage)
|
||||
}
|
||||
if next.setup.aimlapi != nil {
|
||||
t.Fatal("completed AIMLAPI sub-flow should be cleared when returning to providers")
|
||||
}
|
||||
if got := next.setupProvider().ID; got != "aimlapi" {
|
||||
t.Fatalf("selected provider = %q, want aimlapi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupAimlapiCtrlCCancelsInFlightCheckout(t *testing.T) {
|
||||
cancelled := false
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{Visible: true}})
|
||||
m.setup.stage = setupStageAimlapi
|
||||
m.setup.aimlapi = &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
topupCancel: func() { cancelled = true },
|
||||
}
|
||||
|
||||
_, cmd := m.Update(testKeyCtrl('c'))
|
||||
if !cancelled {
|
||||
t.Fatal("Ctrl+C did not cancel the in-flight AIMLAPI checkout")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("Ctrl+C should still quit after cancelling the checkout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupAimlapiEnvKeyUsesResolvedInferenceEndpoint(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "env-secret")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "https://staging.example.test/v1")
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{{
|
||||
ID: "aimlapi", Name: "aimlapi.com", EnvVar: "AIMLAPI_API_KEY", RequiresAuth: true,
|
||||
}},
|
||||
}})
|
||||
|
||||
if got := m.setupBaseURL(m.setupProvider()); got != "https://staging.example.test/v1" {
|
||||
t.Fatalf("setup base URL = %q, want resolved override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiKeyInputUsesAutomaticVerificationHint(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepKeyInput}
|
||||
view := plainRender(t, strings.Join(state.view(64, ""), "\n"))
|
||||
assertContains(t, view, "Your API key will be hidden and verified automatically.")
|
||||
assertNotContains(t, view, "Pasted keys are hidden")
|
||||
}
|
||||
|
||||
func TestAimlapiPastedKeyStartsBalanceValidation(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepKeyInput, apiKey: " key_test "}
|
||||
|
||||
cmd, outcome := state.handleKey(testKey(tea.KeyEnter))
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("pasted key should start balance validation")
|
||||
}
|
||||
if outcome != aimlapiContinue || !state.busy {
|
||||
t.Fatalf("validation returned outcome=%v busy=%v, want continue/true", outcome, state.busy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiOnboardingErrorsStripBodiesAndRedactActiveSecrets(t *testing.T) {
|
||||
const (
|
||||
apiKey = "sk-super-secret"
|
||||
code = "654321"
|
||||
bearer = "session-super-secret"
|
||||
body = "server echoed sk-super-secret 654321 session-super-secret"
|
||||
)
|
||||
newState := func(step aimlapiOnboardStep) *aimlapiOnboardState {
|
||||
return &aimlapiOnboardState{step: step, apiKey: apiKey, code: code, sessionToken: bearer, busy: true}
|
||||
}
|
||||
err := aimlapi.APIError{Message: "onboarding request failed", Status: http.StatusInternalServerError, Body: body}
|
||||
tests := []struct {
|
||||
name string
|
||||
state *aimlapiOnboardState
|
||||
apply func(*aimlapiOnboardState)
|
||||
}{
|
||||
{name: "key validation", state: newState(aimlapiStepKeyInput), apply: func(s *aimlapiOnboardState) { s.applyKeyValidation(aimlapiOnboardMsg{err: err}) }},
|
||||
{name: "account check", state: newState(aimlapiStepEmailInput), apply: func(s *aimlapiOnboardState) { s.applyCheck(aimlapiOnboardMsg{err: err}) }},
|
||||
{name: "code verification", state: newState(aimlapiStepCodeInput), apply: func(s *aimlapiOnboardState) { s.applyToken(aimlapiOnboardMsg{err: err}) }},
|
||||
{name: "key mint", state: newState(aimlapiStepEmailInput), apply: func(s *aimlapiOnboardState) { s.applyKey(aimlapiOnboardMsg{err: err}) }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.apply(test.state)
|
||||
if strings.Contains(test.state.errText, body) || strings.Contains(test.state.errText, apiKey) ||
|
||||
strings.Contains(test.state.errText, code) || strings.Contains(test.state.errText, bearer) {
|
||||
t.Fatalf("credential leaked in error: %q", test.state.errText)
|
||||
}
|
||||
if !strings.Contains(test.state.errText, "HTTP 500") {
|
||||
t.Fatalf("safe status missing from error: %q", test.state.errText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiPastedKeyRejectsUnauthorized(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepKeyInput,
|
||||
apiKey: "bad-key",
|
||||
busy: true,
|
||||
}
|
||||
|
||||
_, outcome := state.apply(aimlapiOnboardMsg{
|
||||
state: state,
|
||||
kind: aimlapiMsgKeyValidation,
|
||||
err: aimlapi.APIError{Status: http.StatusUnauthorized},
|
||||
})
|
||||
|
||||
if outcome != aimlapiContinue || state.busy {
|
||||
t.Fatalf("invalid key returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
|
||||
}
|
||||
if state.step != aimlapiStepKeyInput || state.apiKey != "" {
|
||||
t.Fatalf("invalid key left step=%v key=%q, want key input with cleared key", state.step, state.apiKey)
|
||||
}
|
||||
if state.errText != aimlapi.MsgAPIKeyInvalid {
|
||||
t.Fatalf("error = %q, want %q", state.errText, aimlapi.MsgAPIKeyInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiVerifyCodeLabelsOnlyBadRequestAsIncorrect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantIncorrect bool
|
||||
}{
|
||||
{name: "invalid code", err: aimlapi.APIError{Status: http.StatusBadRequest}, wantIncorrect: true},
|
||||
{name: "throttled", err: aimlapi.APIError{Status: http.StatusTooManyRequests}},
|
||||
{name: "server failure", err: aimlapi.APIError{Status: http.StatusInternalServerError}},
|
||||
{name: "network failure", err: errors.New("connection reset")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepCodeInput, code: "123456", busy: true}
|
||||
state.applyToken(aimlapiOnboardMsg{err: tt.err})
|
||||
if got := state.errText == aimlapi.MsgCodeIncorrect; got != tt.wantIncorrect {
|
||||
t.Fatalf("incorrect-code label = %v, error text %q", got, state.errText)
|
||||
}
|
||||
if state.step != aimlapiStepCodeInput || state.code != "123456" {
|
||||
t.Fatalf("verification retry lost its screen/code: step=%v code=%q", state.step, state.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiPastedKeyValidationCompletesForValidKey(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepKeyInput,
|
||||
apiKey: " key_test ",
|
||||
busy: true,
|
||||
}
|
||||
|
||||
_, outcome := state.apply(aimlapiOnboardMsg{
|
||||
state: state,
|
||||
kind: aimlapiMsgKeyValidation,
|
||||
})
|
||||
|
||||
if outcome != aimlapiContinue || state.busy {
|
||||
t.Fatalf("valid key returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
|
||||
}
|
||||
if state.step != aimlapiStepDone || state.apiKey != "key_test" {
|
||||
t.Fatalf("valid key left step=%v key=%q, want done with trimmed key", state.step, state.apiKey)
|
||||
}
|
||||
if len(state.successLines) != 1 || state.successLines[0] != aimlapi.MsgEverythingRuns {
|
||||
t.Fatalf("success lines = %#v", state.successLines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiKeyBalanceLowBalanceOffersTopUp(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
apiKey: "key_minted",
|
||||
busy: true,
|
||||
}
|
||||
|
||||
_, outcome := state.apply(aimlapiOnboardMsg{
|
||||
state: state,
|
||||
kind: aimlapiMsgKeyBalance,
|
||||
balance: aimlapi.BalanceResult{LowBalance: true},
|
||||
})
|
||||
|
||||
if outcome != aimlapiContinue || state.busy {
|
||||
t.Fatalf("low balance returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
|
||||
}
|
||||
if state.step != aimlapiStepLowBalance || state.lowCursor != 0 {
|
||||
t.Fatalf("low balance left step=%v cursor=%d, want low-balance prompt with cursor 0", state.step, state.lowCursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiTopUpFailureRetainsSessionForRetry(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepProgress, paymentSessionID: "payment-live"}
|
||||
state.applyTopup(aimlapiOnboardMsg{
|
||||
topupOK: true,
|
||||
topup: aimlapiTopupEvent{session: "pcs_resume", hasSession: true},
|
||||
})
|
||||
state.applyTopup(aimlapiOnboardMsg{
|
||||
topupOK: true,
|
||||
topup: aimlapiTopupEvent{done: true, err: errors.New("lost response")},
|
||||
})
|
||||
if state.resumeSessionToken != "pcs_resume" {
|
||||
t.Fatalf("resume token = %q, want retained session", state.resumeSessionToken)
|
||||
}
|
||||
if state.paymentSessionID != "payment-live" {
|
||||
t.Fatalf("payment session id = %q, want retained live id", state.paymentSessionID)
|
||||
}
|
||||
if state.step != aimlapiStepAmountInput {
|
||||
t.Fatalf("step = %v, want retryable amount input", state.step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiRejectsUnknownAccountAction(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepProgress, email: "[email protected]", busy: true}
|
||||
cmd, outcome := state.applyCheck(aimlapiOnboardMsg{check: aimlapi.CheckResult{Action: "future-action"}})
|
||||
|
||||
if cmd != nil || outcome != aimlapiContinue {
|
||||
t.Fatalf("unknown action returned cmd=%v outcome=%v", cmd != nil, outcome)
|
||||
}
|
||||
if state.step != aimlapiStepEmailInput || state.newAccount || state.busy {
|
||||
t.Fatalf("unknown action state = step %v new=%v busy=%v", state.step, state.newAccount, state.busy)
|
||||
}
|
||||
if state.errText != aimlapi.MsgAccountActionInvalid {
|
||||
t.Fatalf("error = %q", state.errText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiChangedTopUpIntentDropsRetainedCheckout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amount int
|
||||
autoTopUp bool
|
||||
wantDrop bool
|
||||
}{
|
||||
{name: "same intent", amount: 2500, autoTopUp: true, wantDrop: false},
|
||||
{name: "changed amount", amount: 5000, autoTopUp: true, wantDrop: true},
|
||||
{name: "changed auto top-up", amount: 2500, autoTopUp: false, wantDrop: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
resumeSessionToken: "pcs_old",
|
||||
paymentSessionID: "payment-old",
|
||||
paymentAmountUSDMinor: 2500,
|
||||
paymentAutoTopUp: true,
|
||||
autoTopUp: test.autoTopUp,
|
||||
}
|
||||
state.prepareTopUpIntent(test.amount)
|
||||
dropped := state.resumeSessionToken == "" && state.paymentSessionID == ""
|
||||
if dropped != test.wantDrop {
|
||||
t.Fatalf("checkout dropped = %v, want %v", dropped, test.wantDrop)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiTopUpSuccessShowsChargedCentAmount(t *testing.T) {
|
||||
state := &aimlapiOnboardState{amount: "20.999", paymentAmountUSDMinor: 2100}
|
||||
lines := state.topUpSuccessLines()
|
||||
if got := strings.Join(lines, "\n"); !strings.Contains(got, "$21.00 has been added") || strings.Contains(got, "20.999") {
|
||||
t.Fatalf("success lines do not show normalized charge: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiTerminalTopUpDropsByKeyIdempotencyID(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
resumeSessionToken: "pcs_old",
|
||||
paymentSessionID: "payment-old",
|
||||
}
|
||||
state.applyTopup(aimlapiOnboardMsg{
|
||||
topupOK: true,
|
||||
topup: aimlapiTopupEvent{
|
||||
done: true, err: errors.New("checkout expired"), session: "", hasSession: true,
|
||||
},
|
||||
})
|
||||
|
||||
if state.resumeSessionToken != "" || state.paymentSessionID != "" {
|
||||
t.Fatalf("terminal checkout retained session ids: resume=%q payment=%q", state.resumeSessionToken, state.paymentSessionID)
|
||||
}
|
||||
if state.step != aimlapiStepAmountInput {
|
||||
t.Fatalf("step = %v, want retryable amount input", state.step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiOnboardingKeepsSharedTickAlive(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{Visible: true}})
|
||||
m.reducedMotion = false
|
||||
m.setup.stage = setupStageAimlapi
|
||||
m.setup.aimlapi = &aimlapiOnboardState{step: aimlapiStepProgress}
|
||||
|
||||
// No agent run is in flight, but the aimlapi onboarding sub-flow must keep the
|
||||
// shared tick loop alive so its spinner-only progress screen keeps re-rendering.
|
||||
updated, cmd := m.Update(m.spinner.Tick())
|
||||
next := updated.(model)
|
||||
if cmd == nil || !next.spinnerTicking {
|
||||
t.Fatal("aimlapi onboarding should keep the shared spinner tick loop alive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiAmountInputIgnoresLeft(t *testing.T) {
|
||||
for _, newAccount := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "existing account", true: "new account"}[newAccount], func(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepAmountInput,
|
||||
newAccount: newAccount,
|
||||
errText: "keep this error",
|
||||
}
|
||||
|
||||
cmd, outcome := state.handleKey(testKey(tea.KeyLeft))
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("left on the top-up amount screen should not start a command")
|
||||
}
|
||||
if outcome != aimlapiContinue {
|
||||
t.Fatalf("outcome = %v, want aimlapiContinue", outcome)
|
||||
}
|
||||
if state.step != aimlapiStepAmountInput {
|
||||
t.Fatalf("step = %v, want amount input", state.step)
|
||||
}
|
||||
if state.amountField != 0 || state.errText != "keep this error" {
|
||||
t.Fatalf("left changed amount-screen state: field=%d error=%q", state.amountField, state.errText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiAutoTopUpToggleUsesBothArrowKeys(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
key rune
|
||||
}{
|
||||
{name: "left", key: tea.KeyLeft},
|
||||
{name: "right", key: tea.KeyRight},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepAmountInput,
|
||||
amountField: 1,
|
||||
autoTopUp: true,
|
||||
}
|
||||
|
||||
cmd, outcome := state.handleKey(testKey(test.key))
|
||||
|
||||
if cmd != nil || outcome != aimlapiContinue {
|
||||
t.Fatalf("arrow returned cmd=%v outcome=%v, want nil/continue", cmd != nil, outcome)
|
||||
}
|
||||
if state.autoTopUp {
|
||||
t.Fatalf("%s did not toggle auto top-up", test.name)
|
||||
}
|
||||
if state.step != aimlapiStepAmountInput {
|
||||
t.Fatalf("step = %v, want amount input", state.step)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiAutoTopUpRendersOnOff(t *testing.T) {
|
||||
idle := aimlapiToggleLine("auto top up > ", true, false)
|
||||
view := plainRender(t, idle)
|
||||
assertContains(t, view, "on/off")
|
||||
assertNotContains(t, view, "yes/no")
|
||||
if !strings.Contains(idle, zeroTheme.ink.Render("on")) ||
|
||||
!strings.Contains(idle, zeroTheme.faint.Render("off")) {
|
||||
t.Fatalf("idle toggle should render selected on white and unselected off faint: %q", idle)
|
||||
}
|
||||
|
||||
focused := aimlapiToggleLine("auto top up > ", false, true)
|
||||
if !strings.Contains(focused, zeroTheme.accent.Render("auto top up > ")) ||
|
||||
!strings.Contains(focused, zeroTheme.faint.Render("on")) ||
|
||||
!strings.Contains(focused, zeroTheme.ink.Render("off")) {
|
||||
t.Fatalf("focused toggle should highlight its prompt, selected off, and dim unselected on: %q", focused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiPickPathShowsNewUserFirstAndRoutesSelections(t *testing.T) {
|
||||
state := &aimlapiOnboardState{step: aimlapiStepPickPath}
|
||||
view := plainRender(t, strings.Join(state.viewPickPath(64), "\n"))
|
||||
newUserIndex := strings.Index(view, aimlapi.MsgPickPathNewUser)
|
||||
haveKeyIndex := strings.Index(view, aimlapi.MsgPickPathHaveKey)
|
||||
if newUserIndex < 0 || haveKeyIndex < 0 || newUserIndex >= haveKeyIndex {
|
||||
t.Fatalf("new-user option should precede have-key option:\n%s", view)
|
||||
}
|
||||
state.pathCursor = 0
|
||||
state.handlePickPathKey(testKey(tea.KeyEnter))
|
||||
if state.step != aimlapiStepEmailInput {
|
||||
t.Fatalf("first option routes to %v, want email input", state.step)
|
||||
}
|
||||
state.step = aimlapiStepPickPath
|
||||
state.pathCursor = 1
|
||||
state.handlePickPathKey(testKey(tea.KeyEnter))
|
||||
if state.step != aimlapiStepKeyInput {
|
||||
t.Fatalf("second option routes to %v, want key input", state.step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstRunAimlapiEnvSkipsGuidedOnboarding(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "env-runtime-secret")
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{{
|
||||
ID: "aimlapi", Name: "aimlapi.com", DefaultModel: "anthropic/claude-sonnet-5",
|
||||
}},
|
||||
}})
|
||||
m.setup.stage = setupStageProvider
|
||||
m.setup.selected = 0
|
||||
|
||||
updated, cmd := m.advanceSetup()
|
||||
next := updated.(model)
|
||||
if next.setup.stage != setupStageModel {
|
||||
t.Fatalf("stage = %v, want model selection", next.setup.stage)
|
||||
}
|
||||
if next.setup.aimlapi != nil {
|
||||
t.Fatal("env credential should not create the guided onboarding state")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("env credential should start authenticated model discovery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiWizardDoesNotFilterDiscoveredModelsByDefault(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.providerWizard = m.newProviderWizard()
|
||||
m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "aimlapi")
|
||||
m.providerWizard.aimlapi = &aimlapiOnboardState{
|
||||
apiKey: "sk-issued",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
}
|
||||
|
||||
next, cmd := m.resolveProviderWizardAimlapi(nil, aimlapiDone)
|
||||
if next.providerWizard.step != providerWizardStepModel || cmd == nil {
|
||||
t.Fatalf("AIMLAPI completion should enter model discovery: step=%v cmd=%v", next.providerWizard.step, cmd != nil)
|
||||
}
|
||||
if next.providerWizard.modelSearch != "" {
|
||||
t.Fatalf("model search = %q, want empty so the full discovered list is visible", next.providerWizard.modelSearch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiAmountDollarUsesInkStyle(t *testing.T) {
|
||||
line := aimlapiAmountInputLine("", "25", 40, true)
|
||||
if !strings.Contains(line, zeroTheme.ink.Render("$")) {
|
||||
t.Fatalf("amount dollar is not rendered with ink style: %q", line)
|
||||
}
|
||||
idle := aimlapiAmountInputLine("25", "25", 40, false)
|
||||
if !strings.Contains(idle, zeroTheme.ink.Render("amount > ")) ||
|
||||
strings.Contains(idle, zeroTheme.accent.Render("amount > ")) {
|
||||
t.Fatalf("unfocused amount prompt should be white: %q", idle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiSafetyBackReturnsToProviderList(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
key rune
|
||||
}{
|
||||
{name: "left", key: tea.KeyLeft},
|
||||
{name: "escape", key: tea.KeyEsc},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{
|
||||
{ID: "openai", Name: "OpenAI"},
|
||||
{ID: "aimlapi", Name: "aimlapi.com"},
|
||||
},
|
||||
}})
|
||||
m.setup.selected = 1
|
||||
m.setup.stage = setupStageSafety
|
||||
m.setup.aimlapi = nil // Cleared after the AIMLAPI connect flow completes.
|
||||
|
||||
updated, cmd := m.Update(testKey(test.key))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("back from Safety should not start a command")
|
||||
}
|
||||
if next.setup.stage != setupStageProvider {
|
||||
t.Fatalf("stage = %v, want provider list", next.setup.stage)
|
||||
}
|
||||
if got := next.setupProvider().ID; got != "aimlapi" {
|
||||
t.Fatalf("selected provider = %q, want aimlapi", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiModelBackReturnsToProviderList(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
key rune
|
||||
}{
|
||||
{name: "left", key: tea.KeyLeft},
|
||||
{name: "escape", key: tea.KeyEsc},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{
|
||||
Visible: true,
|
||||
Providers: []SetupProviderOption{
|
||||
{ID: "openai", Name: "OpenAI"},
|
||||
{ID: "aimlapi", Name: "aimlapi.com"},
|
||||
},
|
||||
}})
|
||||
m.setup.selected = 1
|
||||
m.setup.stage = setupStageModel
|
||||
m.setup.aimlapi = nil // Cleared once AIMLAPI has issued the key.
|
||||
|
||||
updated, cmd := m.Update(testKey(test.key))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("back from AIMLAPI model selection should not start a command")
|
||||
}
|
||||
if next.setup.stage != setupStageProvider {
|
||||
t.Fatalf("stage = %v, want provider list", next.setup.stage)
|
||||
}
|
||||
if got := next.setupProvider().ID; got != "aimlapi" {
|
||||
t.Fatalf("selected provider = %q, want aimlapi", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupCredentialSummaryOAuthToken(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Setup: SetupOptions{Visible: true}})
|
||||
m.setup.oauthMode = true
|
||||
|
||||
@@ -420,7 +420,7 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
|
||||
k = resolved
|
||||
}
|
||||
}
|
||||
models, err := discover(ctx, providerWizardDiscoveryProfile(descriptor, k))
|
||||
models, err := discover(ctx, providerWizardDiscoveryProfile(descriptor, k, authed.BaseURL))
|
||||
return modelPickerModelsDiscoveredMsg{providerID: providerID, models: models, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestOpenProviderManagerListsSavedProviders(t *testing.T) {
|
||||
if len(m.providerWizard.manageRows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(m.providerWizard.manageRows))
|
||||
}
|
||||
rendered := m.providerWizard.render(m.width)
|
||||
rendered := m.providerWizard.render(m.width, m.spinnerGlyph())
|
||||
for _, want := range []string{"opengateway", "backup", "active", "Enter activate"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("manager render missing %q:\n%s", want, rendered)
|
||||
|
||||
+245
-11
@@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/browser"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
@@ -74,6 +76,52 @@ func (m model) applyProviderWizardOAuth(msg providerWizardOAuthMsg) (model, tea.
|
||||
return m, m.providerModelDiscoveryCmd()
|
||||
}
|
||||
|
||||
// applyProviderWizardAimlapiOnboard routes an async aimlapi.com onboarding result
|
||||
// into the wizard's shared sub-flow and reacts to its outcome.
|
||||
func (m model) applyProviderWizardAimlapiOnboard(msg aimlapiOnboardMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil || wizard.aimlapi == nil || wizard.aimlapi != msg.state {
|
||||
return m, nil
|
||||
}
|
||||
cmd, outcome := wizard.aimlapi.apply(msg)
|
||||
return m.resolveProviderWizardAimlapi(cmd, outcome)
|
||||
}
|
||||
|
||||
// handleProviderWizardAimlapiKey delegates a key press to the wizard's aimlapi.com
|
||||
// sub-flow and reacts to its outcome.
|
||||
func (m model) handleProviderWizardAimlapiKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil || wizard.aimlapi == nil {
|
||||
return m, nil
|
||||
}
|
||||
cmd, outcome := wizard.aimlapi.handleKey(msg)
|
||||
return m.resolveProviderWizardAimlapi(cmd, outcome)
|
||||
}
|
||||
|
||||
// resolveProviderWizardAimlapi lands the sub-flow's outcome on the wizard: on a
|
||||
// terminal key it moves the freshly acquired key into the wizard and advances to
|
||||
// model selection (reusing the standard finalize); on cancel it backs out.
|
||||
func (m model) resolveProviderWizardAimlapi(cmd tea.Cmd, outcome aimlapiOutcome) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
switch outcome {
|
||||
case aimlapiDone:
|
||||
wizard.apiKey = wizard.aimlapi.apiKey
|
||||
wizard.baseURL = wizard.aimlapi.baseURL
|
||||
wizard.aimlapi = nil
|
||||
wizard.err = ""
|
||||
wizard.step = providerWizardStepModel
|
||||
return m, m.providerModelDiscoveryCmd()
|
||||
case aimlapiCancel:
|
||||
wizard.aimlapi = nil
|
||||
wizard.err = ""
|
||||
wizard.step = providerWizardStepProvider
|
||||
return m, nil
|
||||
}
|
||||
// Keep the shared spinner tick alive whenever the sub-flow just entered a busy
|
||||
// or progress state, so its animated spinner advances during onboarding.
|
||||
return m, tea.Batch(cmd, m.ensureSpinnerTick())
|
||||
}
|
||||
|
||||
// applyProviderWizardDeviceCode handles phase 1 of device-code login: show the
|
||||
// user_code + verification URI, then kick off phase 2 (the token poll). On error
|
||||
// the redacted message is surfaced and the login is abandoned.
|
||||
@@ -300,6 +348,8 @@ const (
|
||||
providerWizardStepManageKey
|
||||
providerWizardStepEndpoint
|
||||
providerWizardStepName
|
||||
providerWizardStepAimlapiConfigured
|
||||
providerWizardStepAimlapi
|
||||
providerWizardStepCredential
|
||||
providerWizardStepModel
|
||||
providerWizardStepDone
|
||||
@@ -371,14 +421,27 @@ type providerWizardState struct {
|
||||
// Manage-key step: shown when the selected provider already has a stored key.
|
||||
manageKeyCursor int
|
||||
manageProviderName string
|
||||
oauthMode bool
|
||||
oauthPending bool
|
||||
oauthAttemptID int
|
||||
oauthErr string
|
||||
// aimlapiExistingProfile is the persisted credential source selected by the
|
||||
// "Use existing configuration" preflight. aimlapiRuntimeKey is resolved only
|
||||
// for balance/top-up/model requests; it must never replace APIKeyEnv or an
|
||||
// APIKeyStored marker when the profile is saved again.
|
||||
aimlapiConfiguredCursor int
|
||||
aimlapiExistingProfile config.ProviderProfile
|
||||
aimlapiRuntimeKey string
|
||||
aimlapiExistingBusy bool
|
||||
aimlapiExistingGen int
|
||||
aimlapiExistingCancel context.CancelFunc
|
||||
oauthMode bool
|
||||
oauthPending bool
|
||||
oauthAttemptID int
|
||||
oauthErr string
|
||||
// Device-code login (RFC 8628) state while an OAuth login is in flight.
|
||||
oauthDevice bool
|
||||
deviceUserCode string
|
||||
deviceVerificationURI string
|
||||
// aimlapi holds the shared aimlapi.com onboarding sub-flow while
|
||||
// the wizard is on providerWizardStepAimlapi.
|
||||
aimlapi *aimlapiOnboardState
|
||||
// Manager state (provider_manager.go): the list-first /provider surface.
|
||||
manage bool
|
||||
manageRows []providerManagerRow
|
||||
@@ -453,6 +516,34 @@ func (wizard *providerWizardState) oauthResultMatches(providerID string, attempt
|
||||
return wizard.currentProvider().ID == providerID && wizard.oauthAttemptID == attemptID
|
||||
}
|
||||
|
||||
// resetAimlapiOnboard drops any in-flight aimlapi.com sub-flow (cancelling a
|
||||
// running top-up stream) when the selected provider changes.
|
||||
func (wizard *providerWizardState) resetAimlapiOnboard() {
|
||||
if wizard.aimlapi != nil {
|
||||
wizard.aimlapi.cancelStream()
|
||||
wizard.aimlapi = nil
|
||||
}
|
||||
wizard.clearAimlapiExisting()
|
||||
}
|
||||
|
||||
// enterAimlapi starts the shared aimlapi.com onboarding sub-flow.
|
||||
func (wizard *providerWizardState) enterAimlapi() {
|
||||
wizard.clearAimlapiExisting()
|
||||
wizard.aimlapi = newAimlapiOnboard(browser.OpenURL)
|
||||
wizard.step = providerWizardStepAimlapi
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) clearAimlapiExisting() {
|
||||
if wizard.aimlapiExistingCancel != nil {
|
||||
wizard.aimlapiExistingCancel()
|
||||
wizard.aimlapiExistingCancel = nil
|
||||
}
|
||||
wizard.aimlapiExistingProfile = config.ProviderProfile{}
|
||||
wizard.aimlapiRuntimeKey = ""
|
||||
wizard.aimlapiExistingBusy = false
|
||||
wizard.aimlapiConfiguredCursor = 0
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) currentModel() providerWizardModel {
|
||||
if wizard == nil {
|
||||
return providerWizardModel{}
|
||||
@@ -501,6 +592,7 @@ func (wizard *providerWizardState) move(delta int) {
|
||||
wizard.modelLoadError = ""
|
||||
wizard.oauthPending = false
|
||||
wizard.oauthErr = ""
|
||||
wizard.resetAimlapiOnboard()
|
||||
wizard.refreshModels()
|
||||
case providerWizardStepModel:
|
||||
wizard.refreshModels()
|
||||
@@ -556,6 +648,8 @@ func (wizard *providerWizardState) advance() {
|
||||
wizard.err = ""
|
||||
if providerWizardNeedsEndpoint(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
} else if providerWizardIsAimlapi(wizard.currentProvider()) {
|
||||
wizard.enterAimlapi()
|
||||
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepCredential
|
||||
} else {
|
||||
@@ -570,7 +664,9 @@ func (wizard *providerWizardState) advance() {
|
||||
wizard.step = providerWizardStepName
|
||||
case providerWizardStepName:
|
||||
wizard.err = ""
|
||||
if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
if providerWizardIsAimlapi(wizard.currentProvider()) {
|
||||
wizard.enterAimlapi()
|
||||
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepCredential
|
||||
} else {
|
||||
wizard.step = providerWizardStepModel
|
||||
@@ -618,6 +714,15 @@ func (wizard *providerWizardState) retreat() {
|
||||
wizard.providerSearch = ""
|
||||
case providerWizardStepName:
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
case providerWizardStepAimlapi:
|
||||
wizard.resetAimlapiOnboard()
|
||||
if providerWizardNeedsProfileName(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepName
|
||||
} else if providerWizardNeedsEndpoint(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
} else {
|
||||
wizard.step = providerWizardStepProvider
|
||||
}
|
||||
case providerWizardStepCredential:
|
||||
if providerWizardNeedsProfileName(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepName
|
||||
@@ -627,7 +732,13 @@ func (wizard *providerWizardState) retreat() {
|
||||
wizard.step = providerWizardStepProvider
|
||||
}
|
||||
case providerWizardStepModel:
|
||||
if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
if providerWizardIsAimlapi(wizard.currentProvider()) {
|
||||
if aimlapiProfile(wizard.aimlapiExistingProfile) {
|
||||
wizard.step = providerWizardStepAimlapiConfigured
|
||||
} else {
|
||||
wizard.step = providerWizardStepProvider
|
||||
}
|
||||
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepCredential
|
||||
} else if providerWizardNeedsEndpoint(wizard.currentProvider()) {
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
@@ -677,6 +788,10 @@ func providerWizardNeedsCredential(provider providercatalog.Descriptor) bool {
|
||||
return provider.RequiresAuth && !provider.Local && len(provider.AuthEnvVars) > 0
|
||||
}
|
||||
|
||||
func providerWizardIsAimlapi(provider providercatalog.Descriptor) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(provider.ID), "aimlapi")
|
||||
}
|
||||
|
||||
func providerWizardNeedsEndpoint(provider providercatalog.Descriptor) bool {
|
||||
switch provider.ID {
|
||||
case "custom-openai-compatible", "custom-anthropic-compatible":
|
||||
@@ -713,6 +828,10 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
wizard.oauthDevice = false
|
||||
wizard.deviceUserCode = ""
|
||||
wizard.deviceVerificationURI = ""
|
||||
// This branch preempts the aimlapi.com key delegation below, so cancel the
|
||||
// embedded sub-flow here too — otherwise its in-flight CreateKey/checkout
|
||||
// keeps running against a background context and mints a key we discard.
|
||||
wizard.resetAimlapiOnboard()
|
||||
wizard.err = ""
|
||||
wizard.oauthErr = ""
|
||||
wizard.step = providerWizardStepManage
|
||||
@@ -812,6 +931,34 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
return m.advanceProviderWizard()
|
||||
}
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepAimlapi {
|
||||
return m.handleProviderWizardAimlapiKey(msg)
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepAimlapiConfigured {
|
||||
if m.providerWizard.aimlapiExistingBusy {
|
||||
if keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft) {
|
||||
m.providerWizard.aimlapiExistingGen++
|
||||
m.providerWizard.clearAimlapiExisting()
|
||||
m.providerWizard.step = providerWizardStepProvider
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
m.providerWizard = nil
|
||||
case keyIs(msg, tea.KeyLeft):
|
||||
m.providerWizard.clearAimlapiExisting()
|
||||
m.providerWizard.step = providerWizardStepProvider
|
||||
case keyIs(msg, tea.KeyUp) || keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
|
||||
m.providerWizard.aimlapiConfiguredCursor = (m.providerWizard.aimlapiConfiguredCursor + 1) % 2
|
||||
case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight):
|
||||
if m.providerWizard.aimlapiConfiguredCursor == 0 {
|
||||
return m.checkExistingAimlapiBalance()
|
||||
}
|
||||
m.providerWizard.enterAimlapi()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepCredential {
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
@@ -893,6 +1040,10 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
|
||||
m.providerWizard.appendBaseURL([]rune(content))
|
||||
case providerWizardStepName:
|
||||
m.providerWizard.appendProfileName([]rune(content))
|
||||
case providerWizardStepAimlapi:
|
||||
if m.providerWizard.aimlapi != nil {
|
||||
m.providerWizard.aimlapi.appendInput(content)
|
||||
}
|
||||
case providerWizardStepCredential:
|
||||
m.providerWizard.appendAPIKey([]rune(content))
|
||||
case providerWizardStepModel:
|
||||
@@ -1082,6 +1233,18 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
|
||||
modelChoice := wizard.currentModel()
|
||||
profile := providerWizardProfile(provider, modelChoice.ID, wizard.apiKey, wizard.baseURL, wizard.profileName)
|
||||
runtimeProfile := providerWizardRuntimeProfile(profile)
|
||||
usingExistingAimlapi := aimlapiProfile(wizard.aimlapiExistingProfile)
|
||||
preserveExistingCredentialReference := false
|
||||
if usingExistingAimlapi {
|
||||
// Preserve the configured credential source exactly. The resolved key is
|
||||
// runtime-only and must not turn APIKeyEnv or APIKeyStored into a captured
|
||||
// secret merely because the user changed models or topped up.
|
||||
profile = wizard.aimlapiExistingProfile
|
||||
profile.Model = modelChoice.ID
|
||||
runtimeProfile = profile
|
||||
runtimeProfile.APIKey = wizard.aimlapiRuntimeKey
|
||||
preserveExistingCredentialReference = strings.TrimSpace(profile.APIKeyEnv) != "" || profile.APIKeyStored
|
||||
}
|
||||
|
||||
// Build and persist into LOCALS first, committing live state only once BOTH
|
||||
// succeed. A persist failure (read-only config, disk full) must not leave the
|
||||
@@ -1102,7 +1265,9 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
|
||||
// store before persisting, so config.json never holds the cleartext. The
|
||||
// provider was already built above from runtimeProfile, which has the key.
|
||||
secret := profile.APIKey
|
||||
profile = config.SecureProviderProfile(profile, m.userConfigPath)
|
||||
if !preserveExistingCredentialReference {
|
||||
profile = config.SecureProviderProfile(profile, m.userConfigPath)
|
||||
}
|
||||
if _, err := config.UpsertProvider(m.userConfigPath, profile, true); err != nil {
|
||||
wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{secret, profile.APIKey}})
|
||||
return m, nil // nothing committed to live state yet
|
||||
@@ -1193,10 +1358,12 @@ func (m model) providerWizardOverlay(width int) string {
|
||||
if m.providerWizard == nil {
|
||||
return ""
|
||||
}
|
||||
return m.providerWizard.render(width)
|
||||
return m.providerWizard.render(width, m.spinnerGlyph())
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) render(width int) string {
|
||||
// render's spinner glyph is used only by the embedded aimlapi.com onboarding
|
||||
// sub-view; it is the shared global run spinner (spinnerGlyph()), same as everywhere.
|
||||
func (wizard *providerWizardState) render(width int, spinner string) string {
|
||||
if wizard == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -1233,6 +1400,12 @@ func (wizard *providerWizardState) render(width int) string {
|
||||
lines = append(lines, wizard.renderEndpointStep(innerWidth)...)
|
||||
case providerWizardStepName:
|
||||
lines = append(lines, wizard.renderNameStep(innerWidth)...)
|
||||
case providerWizardStepAimlapiConfigured:
|
||||
lines = append(lines, wizard.renderAimlapiConfiguredStep(innerWidth, spinner)...)
|
||||
case providerWizardStepAimlapi:
|
||||
if wizard.aimlapi != nil {
|
||||
lines = append(lines, wizard.aimlapi.view(innerWidth, spinner)...)
|
||||
}
|
||||
case providerWizardStepCredential:
|
||||
lines = append(lines, wizard.renderCredentialStep(innerWidth)...)
|
||||
case providerWizardStepModel:
|
||||
@@ -1306,6 +1479,10 @@ func (wizard *providerWizardState) footerText() string {
|
||||
return "Enter continue ← back Esc close"
|
||||
case providerWizardStepName:
|
||||
return "Enter/→ continue ← back Esc close"
|
||||
case providerWizardStepAimlapiConfigured:
|
||||
return "↑/↓ move Enter/→ select ← back Esc close"
|
||||
case providerWizardStepAimlapi:
|
||||
return "type/↑↓ choose Enter/→ continue ← back Esc close"
|
||||
case providerWizardStepModel:
|
||||
if canRight {
|
||||
return "↑/↓ move Enter/→ continue ← back Esc close"
|
||||
@@ -1329,7 +1506,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
|
||||
switch step {
|
||||
case providerWizardStepProvider:
|
||||
target = providerWizardProviderWidth
|
||||
case providerWizardStepModel, providerWizardStepManage:
|
||||
case providerWizardStepModel, providerWizardStepManage, providerWizardStepAimlapi, providerWizardStepAimlapiConfigured:
|
||||
target = providerWizardModelWidth
|
||||
}
|
||||
target = minInt(target, width)
|
||||
@@ -1367,6 +1544,16 @@ func providerWizardStepLine(wizard *providerWizardState) string {
|
||||
stepLabel{providerWizardStepModel, "3 model"},
|
||||
stepLabel{providerWizardStepDone, "4 ready"},
|
||||
)
|
||||
case providerWizardIsAimlapi(wizard.currentProvider()):
|
||||
connectStep := providerWizardStepAimlapi
|
||||
if aimlapiProfile(wizard.aimlapiExistingProfile) {
|
||||
connectStep = providerWizardStepAimlapiConfigured
|
||||
}
|
||||
steps = append(steps,
|
||||
stepLabel{connectStep, "3 connect"},
|
||||
stepLabel{providerWizardStepModel, "4 model"},
|
||||
stepLabel{providerWizardStepDone, "5 ready"},
|
||||
)
|
||||
case providerWizardNeedsEndpoint(wizard.currentProvider()):
|
||||
steps = append(steps,
|
||||
stepLabel{providerWizardStepEndpoint, "3 endpoint"},
|
||||
@@ -1411,6 +1598,32 @@ func (wizard *providerWizardState) renderMethodStep(width int) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) renderAimlapiConfiguredStep(width int, spinner string) []string {
|
||||
lines := []string{zeroTheme.accent.Render("aimlapi.com is already configured")}
|
||||
if wizard.aimlapiExistingBusy {
|
||||
glyph := strings.TrimSpace(spinner)
|
||||
if glyph == "" {
|
||||
glyph = "•"
|
||||
}
|
||||
return append(lines, "", zeroTheme.faint.Render(glyph+" checking balance"))
|
||||
}
|
||||
options := []struct{ label, hint string }{
|
||||
{"Use existing configuration", "Continue with your saved API key"},
|
||||
{"Configure again", "Set up a new key or switch accounts"},
|
||||
}
|
||||
for index, option := range options {
|
||||
marker := " "
|
||||
style := zeroTheme.ink
|
||||
if index == wizard.aimlapiConfiguredCursor {
|
||||
marker = "❯ "
|
||||
style = zeroTheme.accent.Bold(true)
|
||||
}
|
||||
lines = append(lines, marker+style.Render(option.label))
|
||||
lines = append(lines, " "+zeroTheme.faint.Render(option.hint))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// renderManageKeyStep shows keep/replace/remove for a provider that already has a
|
||||
// key in the encrypted credential store.
|
||||
func (wizard *providerWizardState) renderManageKeyStep(width int) []string {
|
||||
@@ -1824,14 +2037,25 @@ func maskedProviderWizardKey(value string) string {
|
||||
}
|
||||
|
||||
func providerWizardProfile(provider providercatalog.Descriptor, model string, apiKey string, baseURL string, profileName string) config.ProviderProfile {
|
||||
resolvedBaseURL := firstProviderDisplayValue(strings.TrimSpace(baseURL), provider.DefaultBaseURL)
|
||||
profile := config.ProviderProfile{
|
||||
Name: providerWizardDisplayName(provider, baseURL, profileName),
|
||||
ProviderKind: providerWizardProviderKind(provider),
|
||||
CatalogID: provider.ID,
|
||||
BaseURL: firstProviderDisplayValue(strings.TrimSpace(baseURL), provider.DefaultBaseURL),
|
||||
BaseURL: resolvedBaseURL,
|
||||
APIFormat: providerWizardAPIFormat(provider),
|
||||
Model: firstProviderDisplayValue(model, provider.DefaultModel),
|
||||
}
|
||||
// Catalog custom headers (e.g. aimlapi.com's partner attribution) belong to the
|
||||
// catalog endpoint: the resolver only applies them when the base URL is the
|
||||
// default, so a profile built against a staging/proxy/custom override must not
|
||||
// bake them in either — otherwise attribution leaks to an arbitrary host.
|
||||
if sameProviderBaseURL(resolvedBaseURL, provider.DefaultBaseURL) {
|
||||
profile.CustomHeaders = maps.Clone(provider.CustomHeaders)
|
||||
if providerWizardIsAimlapi(provider) {
|
||||
profile.CustomHeaders = aimlapi.WithResolvedPartnerHeader(profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
|
||||
profile.APIKey = apiKey
|
||||
} else if env := firstProviderDisplayValue(provider.AuthEnvVars...); provider.RequiresAuth && env != "" && !provider.Custom {
|
||||
@@ -1845,6 +2069,16 @@ func providerWizardProfile(provider providercatalog.Descriptor, model string, ap
|
||||
return profile
|
||||
}
|
||||
|
||||
// sameProviderBaseURL reports whether two base URLs address the same endpoint,
|
||||
// normalizing case and a trailing slash — mirroring the resolver's sameBaseURL so
|
||||
// the wizard bakes catalog headers on exactly the endpoints the resolver would.
|
||||
func sameProviderBaseURL(left string, right string) bool {
|
||||
return strings.EqualFold(
|
||||
strings.TrimRight(strings.TrimSpace(left), "/"),
|
||||
strings.TrimRight(strings.TrimSpace(right), "/"),
|
||||
)
|
||||
}
|
||||
|
||||
func providerWizardEndpointError(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
@@ -3,11 +3,14 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/browser"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
@@ -24,6 +27,13 @@ type providerModelsDiscoveredMsg struct {
|
||||
secrets []string
|
||||
}
|
||||
|
||||
type aimlapiExistingBalanceMsg struct {
|
||||
wizard *providerWizardState
|
||||
gen int
|
||||
balance aimlapi.BalanceResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (m model) advanceProviderWizard() (model, tea.Cmd) {
|
||||
if m.providerWizard == nil {
|
||||
return m, nil
|
||||
@@ -43,6 +53,18 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) {
|
||||
// A non-OAuth provider that already has a key in the credential store: offer
|
||||
// keep/replace/remove before re-entering credentials.
|
||||
if m.providerWizard.step == providerWizardStepProvider && !m.providerWizard.oauthMode {
|
||||
if providerWizardIsAimlapi(m.providerWizard.currentProvider()) {
|
||||
if profile, runtimeKey, ok := m.existingAimlapiConfiguration(); ok {
|
||||
m.providerWizard.aimlapiExistingProfile = profile
|
||||
m.providerWizard.aimlapiRuntimeKey = runtimeKey
|
||||
m.providerWizard.aimlapiConfiguredCursor = 0
|
||||
m.providerWizard.err = ""
|
||||
m.providerWizard.step = providerWizardStepAimlapiConfigured
|
||||
return m, nil
|
||||
}
|
||||
m.providerWizard.enterAimlapi()
|
||||
return m, nil
|
||||
}
|
||||
if name, ok := m.wizardProviderStoredKey(m.providerWizard.currentProvider()); ok {
|
||||
// Generic/custom providers (custom-openai-compatible etc.) all share
|
||||
// the same CatalogID — matching on CatalogID would block creating a
|
||||
@@ -67,6 +89,133 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// existingAimlapiConfiguration returns a usable persisted credential source and
|
||||
// its current runtime key. The active aimlapi profile wins; otherwise the first
|
||||
// usable saved profile is used. A bare AIMLAPI_API_KEY is represented as an env
|
||||
// profile so choosing it never snapshots the secret into the credential store.
|
||||
func (m model) existingAimlapiConfiguration() (config.ProviderProfile, string, bool) {
|
||||
descriptor, descriptorErr := providercatalog.Require("aimlapi")
|
||||
profiles := append([]config.ProviderProfile(nil), m.savedProviders...)
|
||||
activeName := strings.TrimSpace(m.providerProfile.Name)
|
||||
if activeName != "" {
|
||||
for index, profile := range profiles {
|
||||
if strings.EqualFold(strings.TrimSpace(profile.Name), activeName) && aimlapiProfile(profile) {
|
||||
profiles[0], profiles[index] = profiles[index], profiles[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var store config.APIKeyGetter
|
||||
if strings.TrimSpace(m.userConfigPath) != "" {
|
||||
store, _ = config.ProviderKeyStoreAt(filepath.Dir(m.userConfigPath))
|
||||
}
|
||||
for _, persisted := range profiles {
|
||||
if !aimlapiProfile(persisted) {
|
||||
continue
|
||||
}
|
||||
// The guided balance/top-up client talks to AIMLAPI's production account
|
||||
// API. Never send a credential saved for a proxy, staging service, or other
|
||||
// custom inference endpoint to that API. Such profiles remain available to
|
||||
// the normal provider runtime; they are simply ineligible for this preflight.
|
||||
if descriptorErr != nil || (strings.TrimSpace(persisted.BaseURL) != "" && !sameProviderBaseURL(persisted.BaseURL, descriptor.DefaultBaseURL)) {
|
||||
continue
|
||||
}
|
||||
resolved := persisted
|
||||
if strings.TrimSpace(resolved.APIKey) == "" && strings.TrimSpace(resolved.APIKeyEnv) != "" {
|
||||
resolved.APIKey = strings.TrimSpace(os.Getenv(resolved.APIKeyEnv))
|
||||
}
|
||||
resolved = config.ApplyStoredAPIKey(resolved, store)
|
||||
if key := strings.TrimSpace(resolved.APIKey); key != "" {
|
||||
// ResolvedConfig materializes APIKeyEnv into APIKey for runtime use.
|
||||
// Strip that materialized value (and any loaded stored key) from the
|
||||
// persisted copy so finalization retains the reference, not the secret.
|
||||
if strings.TrimSpace(persisted.APIKeyEnv) != "" || persisted.APIKeyStored {
|
||||
persisted.APIKey = ""
|
||||
}
|
||||
return persisted, key, true
|
||||
}
|
||||
}
|
||||
if key := strings.TrimSpace(os.Getenv("AIMLAPI_API_KEY")); key != "" {
|
||||
if descriptorErr == nil {
|
||||
profile := providerWizardProfile(
|
||||
descriptor,
|
||||
descriptor.DefaultModel,
|
||||
"",
|
||||
aimlapi.ResolveEndpoints().InferenceBaseURL,
|
||||
"",
|
||||
)
|
||||
return profile, key, true
|
||||
}
|
||||
}
|
||||
return config.ProviderProfile{}, "", false
|
||||
}
|
||||
|
||||
func aimlapiProfile(profile config.ProviderProfile) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(profile.CatalogID), "aimlapi") ||
|
||||
strings.EqualFold(strings.TrimSpace(profile.Name), "aimlapi")
|
||||
}
|
||||
|
||||
func (m model) checkExistingAimlapiBalance() (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil || wizard.step != providerWizardStepAimlapiConfigured || wizard.aimlapiExistingBusy {
|
||||
return m, nil
|
||||
}
|
||||
wizard.aimlapiExistingBusy = true
|
||||
wizard.err = ""
|
||||
wizard.aimlapiExistingGen++
|
||||
gen := wizard.aimlapiExistingGen
|
||||
key := wizard.aimlapiRuntimeKey
|
||||
endpoints := aimlapi.ResolveEndpoints()
|
||||
if baseURL := strings.TrimSpace(wizard.aimlapiExistingProfile.BaseURL); baseURL != "" {
|
||||
endpoints.InferenceBaseURL = baseURL
|
||||
} else if descriptor, ok := providercatalog.Get("aimlapi"); ok {
|
||||
// An empty stored BaseURL means the catalog endpoint. Pin it explicitly so
|
||||
// a process-wide inference override cannot redirect this saved key.
|
||||
endpoints.InferenceBaseURL = descriptor.DefaultBaseURL
|
||||
}
|
||||
wizard.baseURL = endpoints.InferenceBaseURL
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 12*time.Second)
|
||||
wizard.aimlapiExistingCancel = cancel
|
||||
cmd := func() tea.Msg {
|
||||
balance, err := aimlapi.NewClient(endpoints, nil).GetBalance(ctx, key)
|
||||
return aimlapiExistingBalanceMsg{wizard: wizard, gen: gen, balance: balance, err: err}
|
||||
}
|
||||
return m, tea.Batch(cmd, m.ensureSpinnerTick())
|
||||
}
|
||||
|
||||
func (m model) applyExistingAimlapiBalance(msg aimlapiExistingBalanceMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil || wizard != msg.wizard || wizard.step != providerWizardStepAimlapiConfigured || msg.gen != wizard.aimlapiExistingGen {
|
||||
return m, nil
|
||||
}
|
||||
if wizard.aimlapiExistingCancel != nil {
|
||||
wizard.aimlapiExistingCancel()
|
||||
wizard.aimlapiExistingCancel = nil
|
||||
}
|
||||
wizard.aimlapiExistingBusy = false
|
||||
if msg.err != nil {
|
||||
if aimlapiIsUnauthorized(msg.err) {
|
||||
wizard.err = "The existing AIMLAPI API key is invalid. Choose Configure again."
|
||||
} else {
|
||||
wizard.err = redaction.ErrorMessage(msg.err, redaction.Options{ExtraSecretValues: []string{wizard.aimlapiRuntimeKey}})
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
wizard.apiKey = wizard.aimlapiRuntimeKey
|
||||
if msg.balance.LowBalance {
|
||||
state := newAimlapiOnboard(browser.OpenURL)
|
||||
state.apiKey = wizard.aimlapiRuntimeKey
|
||||
state.baseURL = wizard.baseURL
|
||||
state.byKey = true
|
||||
state.step = aimlapiStepLowBalance
|
||||
wizard.aimlapi = state
|
||||
wizard.step = providerWizardStepAimlapi
|
||||
return m, nil
|
||||
}
|
||||
wizard.step = providerWizardStepModel
|
||||
return m, m.providerModelDiscoveryCmd()
|
||||
}
|
||||
|
||||
func (m model) providerModelDiscoveryCmd() tea.Cmd {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil {
|
||||
@@ -96,6 +245,7 @@ func (m model) providerModelDiscoveryCmd() tea.Cmd {
|
||||
wizard.discoveryToken++
|
||||
token := wizard.discoveryToken
|
||||
providerID := provider.ID
|
||||
baseURL := wizard.baseURL
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
@@ -105,7 +255,7 @@ func (m model) providerModelDiscoveryCmd() tea.Cmd {
|
||||
apiKey = resolved
|
||||
}
|
||||
}
|
||||
profile := providerWizardDiscoveryProfile(provider, apiKey)
|
||||
profile := providerWizardDiscoveryProfile(provider, apiKey, baseURL)
|
||||
models, err := discover(ctx, profile)
|
||||
return providerModelsDiscoveredMsg{providerID: providerID, token: token, models: models, err: err, secrets: []string{apiKey, profile.APIKey}}
|
||||
}
|
||||
@@ -165,8 +315,8 @@ func providerWizardModelsSource(models []providermodeldiscovery.Model) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func providerWizardDiscoveryProfile(provider providercatalog.Descriptor, apiKey string) config.ProviderProfile {
|
||||
profile := providerWizardProfile(provider, provider.DefaultModel, apiKey, "", "")
|
||||
func providerWizardDiscoveryProfile(provider providercatalog.Descriptor, apiKey string, baseURL string) config.ProviderProfile {
|
||||
profile := providerWizardProfile(provider, provider.DefaultModel, apiKey, baseURL, "")
|
||||
if strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.APIKeyEnv) != "" {
|
||||
profile.APIKey = strings.TrimSpace(os.Getenv(profile.APIKeyEnv))
|
||||
}
|
||||
|
||||
@@ -5,13 +5,18 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/aimlapi"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
@@ -53,7 +58,7 @@ func TestProviderCommandOpensOnboardingWizard(t *testing.T) {
|
||||
updated, _ = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
listView := plainRender(t, next.View())
|
||||
for _, want := range []string{"Choose provider", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
|
||||
for _, want := range []string{"Choose provider", "aimlapi.com", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
|
||||
assertContains(t, listView, want)
|
||||
}
|
||||
}
|
||||
@@ -1167,6 +1172,39 @@ func TestProviderWizardManageKeyReplaceAndKeep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerEscCancelsEmbeddedAimlapiFlow(t *testing.T) {
|
||||
opCtx, opCancel := context.WithCancel(context.Background())
|
||||
topupCtx, topupCancel := context.WithCancel(context.Background())
|
||||
state := &aimlapiOnboardState{
|
||||
step: aimlapiStepProgress,
|
||||
opCancel: opCancel,
|
||||
topupCancel: topupCancel,
|
||||
}
|
||||
m := model{providerWizard: &providerWizardState{
|
||||
manage: true,
|
||||
step: providerWizardStepAimlapi,
|
||||
aimlapi: state,
|
||||
}}
|
||||
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEsc))
|
||||
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("manager escape did not return to provider list: %+v", next.providerWizard)
|
||||
}
|
||||
if next.providerWizard.aimlapi != nil {
|
||||
t.Fatal("manager escape retained embedded aimlapi state")
|
||||
}
|
||||
select {
|
||||
case <-opCtx.Done():
|
||||
default:
|
||||
t.Fatal("pending account/key operation was not cancelled")
|
||||
}
|
||||
select {
|
||||
case <-topupCtx.Done():
|
||||
default:
|
||||
t.Fatal("pending top-up stream was not cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceProviderWizardCustomSkipsManageKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1232,6 +1270,264 @@ func TestAdvanceProviderWizardNamedShowsManageKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiConfigurationUsesEnvWithoutCapturingLiteral(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "aimlapi-env-secret")
|
||||
m := model{savedProviders: []config.ProviderProfile{{
|
||||
Name: "aimlapi", CatalogID: "aimlapi", APIKeyEnv: "AIMLAPI_API_KEY",
|
||||
// ResolvedConfig materializes the env value here before the TUI sees it.
|
||||
APIKey: "aimlapi-env-secret",
|
||||
}}}
|
||||
|
||||
profile, runtimeKey, ok := m.existingAimlapiConfiguration()
|
||||
if !ok {
|
||||
t.Fatal("expected AIMLAPI_API_KEY to be detected")
|
||||
}
|
||||
if runtimeKey != "aimlapi-env-secret" {
|
||||
t.Fatalf("runtime key = %q", runtimeKey)
|
||||
}
|
||||
if profile.APIKeyEnv != "AIMLAPI_API_KEY" || profile.APIKey != "" || profile.APIKeyStored {
|
||||
t.Fatalf("env credential source was not preserved: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiEnvironmentFallbackUsesResolvedEndpoint(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "aimlapi-env-secret")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "https://staging.example.test/v1")
|
||||
|
||||
profile, runtimeKey, ok := (model{}).existingAimlapiConfiguration()
|
||||
if !ok || runtimeKey != "aimlapi-env-secret" {
|
||||
t.Fatalf("configuration = (%+v, %q, %v)", profile, runtimeKey, ok)
|
||||
}
|
||||
if profile.BaseURL != "https://staging.example.test/v1" {
|
||||
t.Fatalf("BaseURL = %q, want resolved override", profile.BaseURL)
|
||||
}
|
||||
if profile.APIKeyEnv != "AIMLAPI_API_KEY" || profile.APIKey != "" {
|
||||
t.Fatalf("credential source changed: %+v", profile)
|
||||
}
|
||||
if len(profile.CustomHeaders) != 0 {
|
||||
t.Fatalf("catalog headers leaked to custom endpoint: %#v", profile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiBalanceUsesProfileEndpointAndCancelsOnAbandon(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
canonical := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
<-r.Context().Done()
|
||||
close(done)
|
||||
}))
|
||||
defer canonical.Close()
|
||||
override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("production profile key was sent to process override: %s", r.URL)
|
||||
}))
|
||||
defer override.Close()
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", override.URL)
|
||||
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepAimlapiConfigured,
|
||||
aimlapiExistingProfile: config.ProviderProfile{
|
||||
Name: "aimlapi", CatalogID: "aimlapi", BaseURL: canonical.URL,
|
||||
},
|
||||
aimlapiRuntimeKey: "production-secret",
|
||||
}
|
||||
m := model{ctx: context.Background(), providerWizard: wizard}
|
||||
_, cmd := m.checkExistingAimlapiBalance()
|
||||
result := make(chan tea.Msg, 1)
|
||||
go func() { result <- execCmd(cmd) }()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("profile endpoint did not receive the balance request")
|
||||
}
|
||||
wizard.clearAimlapiExisting()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("abandoning the preflight did not cancel the balance request")
|
||||
}
|
||||
select {
|
||||
case <-result:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("cancelled balance command did not return")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderWizardAimlapiPartnerOverrideOnlyOnCanonicalEndpoint(t *testing.T) {
|
||||
provider, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
|
||||
|
||||
canonical := providerWizardProfile(provider, provider.DefaultModel, "", provider.DefaultBaseURL, "")
|
||||
if got := canonical.CustomHeaders[aimlapi.PartnerHeaderName]; got != "part_override" {
|
||||
t.Fatalf("canonical partner header = %q, want part_override", got)
|
||||
}
|
||||
custom := providerWizardProfile(provider, provider.DefaultModel, "", "https://staging.example.test/v1", "")
|
||||
if len(custom.CustomHeaders) != 0 {
|
||||
t.Fatalf("partner headers leaked to custom endpoint: %#v", custom.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiConfigurationResolvesStoredKey(t *testing.T) {
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Set("work", "aimlapi-stored-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := model{
|
||||
userConfigPath: configPath,
|
||||
savedProviders: []config.ProviderProfile{{Name: "work", CatalogID: "aimlapi", APIKeyStored: true}},
|
||||
}
|
||||
|
||||
profile, runtimeKey, ok := m.existingAimlapiConfiguration()
|
||||
if !ok || runtimeKey != "aimlapi-stored-secret" {
|
||||
t.Fatalf("stored configuration = (%+v, %q, %v)", profile, runtimeKey, ok)
|
||||
}
|
||||
if !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
|
||||
t.Fatalf("stored credential source was not preserved: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiConfigurationSkipsCustomEndpointCredential(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "")
|
||||
m := model{savedProviders: []config.ProviderProfile{{
|
||||
Name: "aimlapi-staging",
|
||||
CatalogID: "aimlapi",
|
||||
BaseURL: "https://staging.example.test/v1",
|
||||
APIKey: "staging-secret",
|
||||
}}}
|
||||
|
||||
profile, runtimeKey, ok := m.existingAimlapiConfiguration()
|
||||
if ok || profile.Name != "" || runtimeKey != "" {
|
||||
t.Fatalf("custom endpoint credential entered production preflight: (%+v, %q, %v)", profile, runtimeKey, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceAimlapiWithExistingCredentialShowsConfiguredPreflight(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "aimlapi-env-secret")
|
||||
provider, err := providercatalog.Require("aimlapi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := model{providerWizard: &providerWizardState{
|
||||
step: providerWizardStepProvider,
|
||||
providers: []providercatalog.Descriptor{provider},
|
||||
}}
|
||||
|
||||
next, _ := m.advanceProviderWizard()
|
||||
if next.providerWizard.step != providerWizardStepAimlapiConfigured {
|
||||
t.Fatalf("step = %v, want configured preflight", next.providerWizard.step)
|
||||
}
|
||||
if next.providerWizard.aimlapiExistingProfile.APIKeyEnv != "AIMLAPI_API_KEY" {
|
||||
t.Fatalf("profile = %+v", next.providerWizard.aimlapiExistingProfile)
|
||||
}
|
||||
if !maps.Equal(next.providerWizard.aimlapiExistingProfile.CustomHeaders, provider.CustomHeaders) {
|
||||
t.Fatalf("catalog headers were not preserved: %#v", next.providerWizard.aimlapiExistingProfile.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAimlapiConfigureAgainUsesTwoPathOnboarding(t *testing.T) {
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepAimlapiConfigured,
|
||||
aimlapiConfiguredCursor: 1,
|
||||
aimlapiExistingProfile: config.ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", APIKeyEnv: "AIMLAPI_API_KEY"},
|
||||
aimlapiRuntimeKey: "secret",
|
||||
}
|
||||
m := model{providerWizard: wizard}
|
||||
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard.step != providerWizardStepAimlapi || next.providerWizard.aimlapi == nil {
|
||||
t.Fatalf("configure again did not enter onboarding: %+v", next.providerWizard)
|
||||
}
|
||||
if next.providerWizard.aimlapiExistingProfile.Name != "" || next.providerWizard.aimlapiRuntimeKey != "" {
|
||||
t.Fatal("configure again must discard the old credential context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingAimlapiLowBalanceReusesByKeyTopUp(t *testing.T) {
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepAimlapiConfigured,
|
||||
baseURL: "https://validated.example.test/v1",
|
||||
aimlapiExistingProfile: config.ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://validated.example.test/v1", APIKeyEnv: "AIMLAPI_API_KEY"},
|
||||
aimlapiRuntimeKey: "secret",
|
||||
aimlapiExistingBusy: true,
|
||||
aimlapiExistingGen: 4,
|
||||
}
|
||||
m := model{providerWizard: wizard}
|
||||
next, _ := m.applyExistingAimlapiBalance(aimlapiExistingBalanceMsg{
|
||||
wizard: wizard,
|
||||
gen: 4,
|
||||
balance: aimlapi.BalanceResult{
|
||||
LowBalance: true,
|
||||
},
|
||||
})
|
||||
if next.providerWizard.step != providerWizardStepAimlapi || next.providerWizard.aimlapi == nil {
|
||||
t.Fatalf("low balance did not enter aimlapi top-up: %+v", next.providerWizard)
|
||||
}
|
||||
if !next.providerWizard.aimlapi.byKey || next.providerWizard.aimlapi.step != aimlapiStepLowBalance || next.providerWizard.aimlapi.apiKey != "secret" {
|
||||
t.Fatalf("top-up state = %+v", next.providerWizard.aimlapi)
|
||||
}
|
||||
if next.providerWizard.aimlapi.baseURL != "https://validated.example.test/v1" {
|
||||
t.Fatalf("top-up endpoint = %q, want validated endpoint", next.providerWizard.aimlapi.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExistingAimlapiPreservesEnvCredentialSource(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_API_KEY", "runtime-secret")
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"providers":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := providercatalog.Descriptor{
|
||||
ID: "aimlapi", Name: "aimlapi.com", Transport: providercatalog.TransportOpenAICompatible,
|
||||
CustomHeaders: map[string]string{"X-AIMLAPI-Partner-ID": "partner"},
|
||||
}
|
||||
m := model{
|
||||
userConfigPath: configPath,
|
||||
providerWizard: &providerWizardState{
|
||||
step: providerWizardStepDone,
|
||||
providers: []providercatalog.Descriptor{provider},
|
||||
models: []providerWizardModel{{ID: "model/new"}},
|
||||
selectedModel: 0,
|
||||
modelSource: "live",
|
||||
aimlapiExistingProfile: config.ProviderProfile{
|
||||
Name: "aimlapi", CatalogID: "aimlapi", APIKeyEnv: "AIMLAPI_API_KEY", Model: "model/old",
|
||||
CustomHeaders: maps.Clone(provider.CustomHeaders),
|
||||
},
|
||||
aimlapiRuntimeKey: "runtime-secret",
|
||||
},
|
||||
}
|
||||
|
||||
next, _ := m.applyProviderWizard()
|
||||
if next.providerWizard != nil {
|
||||
t.Fatal("successful apply should close the wizard")
|
||||
}
|
||||
cfg := readProviderWizardConfigFixture(t, configPath)
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("providers = %+v", cfg.Providers)
|
||||
}
|
||||
got := cfg.Providers[0]
|
||||
if got.APIKeyEnv != "AIMLAPI_API_KEY" || got.APIKey != "" || got.APIKeyStored {
|
||||
t.Fatalf("credential source changed: %+v", got)
|
||||
}
|
||||
if got.Model != "model/new" {
|
||||
t.Fatalf("model = %q, want model/new", got.Model)
|
||||
}
|
||||
if !maps.Equal(got.CustomHeaders, provider.CustomHeaders) {
|
||||
t.Fatalf("custom headers changed: %#v", got.CustomHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func readProviderWizardConfigFixture(t *testing.T, path string) config.FileConfig {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func (m model) sidebarAvailable() bool {
|
||||
// list) take over the chat column and render at full width; suppress the
|
||||
// second column while any is active so their geometry and mouse hit-testing
|
||||
// stay full-width as before.
|
||||
if m.setup.visible || m.helpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -333,6 +333,38 @@ var everforestPalette = palette{
|
||||
cardPerm: "#96896b",
|
||||
}
|
||||
|
||||
// neonPalette is a neon-on-black color scheme: pitch-black surface with
|
||||
// neon green ink and a cyan accent.
|
||||
var neonPalette = palette{
|
||||
panel: "#050b06",
|
||||
promptBg: "#0c180d",
|
||||
line: "#1c3820",
|
||||
line2: "#2c5230",
|
||||
ink: "#c9ffd2",
|
||||
muted: "#80db8f",
|
||||
faint: "#6eca7d",
|
||||
faintest: "#74c468", // brightened from #58af69 so line numbers quantize to #87d75f and stay AA on the xterm-green addBg (#005f00); still dimmer than faint, keeping the ramp monotonic
|
||||
accent: "#00e5c8",
|
||||
green: "#39ff6a",
|
||||
red: "#ff4d6d",
|
||||
amber: "#f4ff3a",
|
||||
blue: "#22e0ff",
|
||||
gitAdd: "#4fdc6a",
|
||||
gitDel: "#ff6f80",
|
||||
addBg: "#083c10", // quantizes to xterm green #005f00 instead of the same gray as delBg, keeping add/del rows distinct on 256-color terminals
|
||||
delBg: "#3c0810", // quantizes to xterm red #5f0000 (see addBg)
|
||||
addBgWord: "#147828", // quantizes to xterm green #008700, distinct from both addBg's #005f00 and delBgWord's red
|
||||
delBgWord: "#74202e", // quantizes to xterm red #870000 (see addBgWord)
|
||||
permBg: "#2a2a0c",
|
||||
selBg: "#123a1e",
|
||||
addInk: "#ecffdc", // quantizes to #ffffd7, which keeps AA on addBgWord's xterm #008700 (the old #c8ffcf quantized to #d7ffd7 at 4.29:1)
|
||||
delInk: "#ffd0d6",
|
||||
onAccent: "#001410",
|
||||
cardRun: "#1f8a6e",
|
||||
cardErr: "#9a4042", // raised from #8a2f42 for the 3:1 non-text border threshold against the panel (2.43:1 before), holding after xterm-256 quantization too
|
||||
cardPerm: "#8a8a1f",
|
||||
}
|
||||
|
||||
// lightPalette is dark-on-light: a warm cream surface (so cards lift off the
|
||||
// terminal page, which Zero never paints) with near-black ink and an olive-lime
|
||||
// accent that keeps the brand identity while clearing AA on the light panel. The
|
||||
@@ -401,6 +433,38 @@ var solarizedLightPalette = palette{
|
||||
cardPerm: "#c4ae63",
|
||||
}
|
||||
|
||||
// dunePalette is a warm sand-and-cream color scheme: sand/cream surface,
|
||||
// charcoal ink, and a soft amber accent.
|
||||
var dunePalette = palette{
|
||||
panel: "#f2e9d8",
|
||||
promptBg: "#e9dcbf",
|
||||
line: "#d9c7a3",
|
||||
line2: "#c2a97c",
|
||||
ink: "#2b241a",
|
||||
muted: "#473e32",
|
||||
faint: "#554a3a",
|
||||
faintest: "#655648",
|
||||
accent: "#724028", // darkened from #8f5215 for AA on selBg (5.46:1) that also survives ANSI-256 downsampling (quantizes to #444444, 6.47:1 on quantized selBg; the previous #7c4712 quantized to #875f00 at 3.81:1)
|
||||
green: "#38572a",
|
||||
red: "#872d24", // darkened from #963328 so delBg contrast survives ANSI-256 downsampling (true 6.57:1, 256 7.86:1)
|
||||
amber: "#6d4600",
|
||||
blue: "#2f5680", // darkened from #3d6a9e for AA on selBg (was 3.61:1, now 4.90:1)
|
||||
gitAdd: "#38572a",
|
||||
gitDel: "#963328",
|
||||
addBg: "#dcecd0",
|
||||
delBg: "#f5dbd5",
|
||||
addBgWord: "#b9dc9e",
|
||||
delBgWord: "#eebba9",
|
||||
permBg: "#f0dfae",
|
||||
selBg: "#e0cf98",
|
||||
addInk: "#264018",
|
||||
delInk: "#5c1810",
|
||||
onAccent: "#fdf6ea",
|
||||
cardRun: "#b08a4a",
|
||||
cardErr: "#b57560",
|
||||
cardPerm: "#c2a04a",
|
||||
}
|
||||
|
||||
// themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme
|
||||
// token (lowercase, kebab), Label is the picker display text, and IsDark groups the
|
||||
// picker (Dark/Light sections) and drives which built-in `auto` resolves to.
|
||||
@@ -426,8 +490,10 @@ var themeRegistry = []themeEntry{
|
||||
{Name: "solarized-dark", Label: "Solarized Dark", Palette: solarizedDarkPalette, IsDark: true},
|
||||
{Name: "rose-pine", Label: "Rosé Pine", Palette: rosePinePalette, IsDark: true},
|
||||
{Name: "everforest", Label: "Everforest", Palette: everforestPalette, IsDark: true},
|
||||
{Name: "neon", Label: "Neon", Palette: neonPalette, IsDark: true},
|
||||
{Name: "light", Label: "light", Palette: lightPalette, IsDark: false},
|
||||
{Name: "solarized-light", Label: "Solarized Light", Palette: solarizedLightPalette, IsDark: false},
|
||||
{Name: "dune", Label: "Dune", Palette: dunePalette, IsDark: false},
|
||||
}
|
||||
|
||||
// themeByName indexes the registry by lowercased name for O(1) lookup. Built as a
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -261,6 +262,225 @@ func TestHandleThemeCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewThemePresetsWired(t *testing.T) {
|
||||
neon, ok := lookupTheme("neon")
|
||||
if !ok {
|
||||
t.Fatal("theme 'neon' is not registered")
|
||||
}
|
||||
if !neon.IsDark {
|
||||
t.Error("theme 'neon' should be marked as dark")
|
||||
}
|
||||
|
||||
dune, ok := lookupTheme("dune")
|
||||
if !ok {
|
||||
t.Fatal("theme 'dune' is not registered")
|
||||
}
|
||||
if dune.IsDark {
|
||||
t.Error("theme 'dune' should be marked as light")
|
||||
}
|
||||
|
||||
for _, name := range []string{"neon", "dune"} {
|
||||
if !validThemeMode(name) {
|
||||
t.Errorf("%q should be a valid --theme/ZERO_THEME value", name)
|
||||
}
|
||||
}
|
||||
|
||||
if !contains(themeModes, "neon") || !contains(themeModes, "dune") {
|
||||
t.Errorf("themeModes = %v, want it to include neon and dune (the /theme picker list)", themeModes)
|
||||
}
|
||||
}
|
||||
|
||||
// The --theme flag and ZERO_THEME both resolve through resolveThemeMode, and
|
||||
// applyTheme must actually swap zeroTheme to the resolved preset's own palette,
|
||||
// not silently fall back to a built-in.
|
||||
func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) {
|
||||
defer applyTheme(themeDark, true)
|
||||
|
||||
if got := resolveThemeMode("dune", ""); got != themeMode("dune") {
|
||||
t.Fatalf(`resolveThemeMode("dune", "") = %q, want "dune"`, got)
|
||||
}
|
||||
if got := resolveThemeMode("", "neon"); got != themeMode("neon") {
|
||||
t.Fatalf(`resolveThemeMode("", "neon") = %q, want "neon"`, got)
|
||||
}
|
||||
|
||||
applyTheme(themeMode("dune"), true)
|
||||
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, dunePalette.ink) {
|
||||
t.Error("applying \"dune\" did not swap zeroTheme to the dune palette")
|
||||
}
|
||||
|
||||
applyTheme(themeMode("neon"), true)
|
||||
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, neonPalette.ink) {
|
||||
t.Error("applying \"neon\" did not swap zeroTheme to the neon palette")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendedThemeContrastInvariants(t *testing.T) {
|
||||
// Skip validation for old built-in themes if they have established, non-compliant palettes,
|
||||
// but enforce strict compliance on the newly introduced 'neon' and 'dune' themes.
|
||||
for _, entry := range themeRegistry {
|
||||
if entry.Name != "neon" && entry.Name != "dune" {
|
||||
continue
|
||||
}
|
||||
name, pal := entry.Name, entry.Palette
|
||||
|
||||
// Finding 1: Permission and status/success surfaces
|
||||
if r := wcagRatio(t, pal.amber, pal.permBg); r < 4.5 {
|
||||
t.Errorf("%s: amber on permBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.onAccent, pal.amber); r < 4.5 {
|
||||
t.Errorf("%s: onAccent on amber contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.green, pal.panel); r < 4.5 {
|
||||
t.Errorf("%s: green on panel contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.amber, pal.panel); r < 4.5 {
|
||||
t.Errorf("%s: amber on panel contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.red, pal.panel); r < 4.5 {
|
||||
t.Errorf("%s: red on panel contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
|
||||
// Finding 2: Selected row secondary text
|
||||
if r := wcagRatio(t, pal.faint, pal.selBg); r < 4.5 {
|
||||
t.Errorf("%s: faint on selBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.faintest, pal.selBg); r < 4.5 {
|
||||
t.Errorf("%s: faintest on selBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
|
||||
// Finding 3: Diff gutter pairings
|
||||
if r := wcagRatio(t, pal.faintest, pal.addBg); r < 4.5 {
|
||||
t.Errorf("%s: faintest on addBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.faintest, pal.delBg); r < 4.5 {
|
||||
t.Errorf("%s: faintest on delBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.green, pal.addBg); r < 4.5 {
|
||||
t.Errorf("%s: green on addBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
if r := wcagRatio(t, pal.red, pal.delBg); r < 4.5 {
|
||||
t.Errorf("%s: red on delBg contrast %.2f < 4.5", name, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hexChannels splits a #rrggbb token into its 8-bit channels.
|
||||
func hexChannels(t *testing.T, hexColor string) (int, int, int) {
|
||||
t.Helper()
|
||||
h := strings.TrimPrefix(hexColor, "#")
|
||||
v, err := strconv.ParseUint(h, 16, 32)
|
||||
if err != nil || len(h) != 6 {
|
||||
t.Fatalf("bad hex %q", hexColor)
|
||||
}
|
||||
return int((v >> 16) & 0xff), int((v >> 8) & 0xff), int(v & 0xff)
|
||||
}
|
||||
|
||||
// xterm256Hex returns the nearest xterm-256 color (the 6x6x6 cube plus the
|
||||
// 24-step grayscale ramp, by squared RGB distance): how a terminal without
|
||||
// truecolor support downsamples the palette's hex tokens before rendering.
|
||||
func xterm256Hex(t *testing.T, hexColor string) string {
|
||||
t.Helper()
|
||||
r, g, b := hexChannels(t, hexColor)
|
||||
levels := []int{0, 95, 135, 175, 215, 255}
|
||||
bestR, bestG, bestB := 0, 0, 0
|
||||
bestDistance := math.MaxFloat64
|
||||
try := func(cr, cg, cb int) {
|
||||
d := float64((r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb))
|
||||
if d < bestDistance {
|
||||
bestDistance, bestR, bestG, bestB = d, cr, cg, cb
|
||||
}
|
||||
}
|
||||
for _, cr := range levels {
|
||||
for _, cg := range levels {
|
||||
for _, cb := range levels {
|
||||
try(cr, cg, cb)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := 0; i < 24; i++ {
|
||||
gray := 8 + 10*i
|
||||
try(gray, gray, gray)
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x", bestR, bestG, bestB)
|
||||
}
|
||||
|
||||
// Hex-level AA does not guarantee the rendered pairs hold on a 256-color
|
||||
// terminal, which quantizes every token to its nearest xterm entry first.
|
||||
// Guard the pairs that regressed: Dune's selected-row affordances (accent
|
||||
// caret/favorite star and blue local-model dot over selBg via onSel) and
|
||||
// Neon's diff bands, whose previous values all quantized to the same grays.
|
||||
func TestExtendedThemeANSI256Contrast(t *testing.T) {
|
||||
palettes := map[string]palette{}
|
||||
for _, entry := range themeRegistry {
|
||||
palettes[entry.Name] = entry.Palette
|
||||
}
|
||||
q := func(hexColor string) string { return xterm256Hex(t, hexColor) }
|
||||
|
||||
dune := palettes["dune"]
|
||||
for _, pair := range []struct{ name, fg, bg string }{
|
||||
{"accent on selBg", dune.accent, dune.selBg},
|
||||
{"blue on selBg", dune.blue, dune.selBg},
|
||||
{"faintest on selBg", dune.faintest, dune.selBg},
|
||||
{"ink on selBg", dune.ink, dune.selBg},
|
||||
} {
|
||||
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
|
||||
t.Errorf("dune: %s = %.2f < 4.5 after xterm-256 quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
|
||||
}
|
||||
}
|
||||
|
||||
neon := palettes["neon"]
|
||||
greenish := func(hexColor string) bool {
|
||||
r, g, b := hexChannels(t, hexColor)
|
||||
return g > r && g > b
|
||||
}
|
||||
reddish := func(hexColor string) bool {
|
||||
r, g, b := hexChannels(t, hexColor)
|
||||
return r > g && r > b
|
||||
}
|
||||
if q(neon.addBg) == q(neon.delBg) || !greenish(q(neon.addBg)) || !reddish(q(neon.delBg)) {
|
||||
t.Errorf("neon: add/del row bands lose their green/red identity after quantization: addBg %s -> %s, delBg %s -> %s",
|
||||
neon.addBg, q(neon.addBg), neon.delBg, q(neon.delBg))
|
||||
}
|
||||
if q(neon.addBgWord) == q(neon.delBgWord) || !greenish(q(neon.addBgWord)) || !reddish(q(neon.delBgWord)) {
|
||||
t.Errorf("neon: word-span bands lose their green/red identity after quantization: addBgWord %s -> %s, delBgWord %s -> %s",
|
||||
neon.addBgWord, q(neon.addBgWord), neon.delBgWord, q(neon.delBgWord))
|
||||
}
|
||||
if q(neon.addBgWord) == q(neon.addBg) {
|
||||
t.Errorf("neon: changed span is indistinguishable from its add row after quantization (both %s)", q(neon.addBg))
|
||||
}
|
||||
if q(neon.delBgWord) == q(neon.delBg) {
|
||||
t.Errorf("neon: changed span is indistinguishable from its del row after quantization (both %s)", q(neon.delBg))
|
||||
}
|
||||
if r := wcagRatio(t, q(neon.green), q(neon.addBg)); r < 4.5 {
|
||||
t.Errorf("neon: green on addBg = %.2f < 4.5 after quantization", r)
|
||||
}
|
||||
if r := wcagRatio(t, q(neon.red), q(neon.delBg)); r < 4.5 {
|
||||
t.Errorf("neon: red on delBg = %.2f < 4.5 after quantization", r)
|
||||
}
|
||||
// The two rendered diff-content pairs a 256-color terminal actually
|
||||
// shows: line numbers (faintest on addBg/delBg) and highlighted changed
|
||||
// spans (addInk/delInk on their word bands).
|
||||
for _, pair := range []struct{ name, fg, bg string }{
|
||||
{"faintest on addBg", neon.faintest, neon.addBg},
|
||||
{"faintest on delBg", neon.faintest, neon.delBg},
|
||||
{"addInk on addBgWord", neon.addInk, neon.addBgWord},
|
||||
{"delInk on delBgWord", neon.delInk, neon.delBgWord},
|
||||
} {
|
||||
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
|
||||
t.Errorf("neon: %s = %.2f < 4.5 after quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
|
||||
}
|
||||
}
|
||||
// Error-card borders are non-text UI components: they need 3:1 against
|
||||
// the panel (WCAG 1.4.11) to be distinguishable at all, in both truecolor
|
||||
// and 256-color renderings.
|
||||
if r := wcagRatio(t, neon.cardErr, neon.panel); r < 3.0 {
|
||||
t.Errorf("neon: cardErr border on panel = %.2f < 3.0", r)
|
||||
}
|
||||
if r := wcagRatio(t, q(neon.cardErr), q(neon.panel)); r < 3.0 {
|
||||
t.Errorf("neon: cardErr border on panel = %.2f < 3.0 after quantization", r)
|
||||
}
|
||||
}
|
||||
|
||||
func mustR(t *testing.T, hex string) uint32 {
|
||||
t.Helper()
|
||||
r, _, _, _ := lipgloss.Color(hex).RGBA()
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Zero — a fast, multi-provider terminal coding agent.",
|
||||
"type": "module",
|
||||
"module": "bin/zero.js",
|
||||
|
||||
Reference in new issue
Block a user