mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
192b8e36bd | ||
|
|
ab82c7b72f | ||
|
|
a78414f84e | ||
|
|
f7503d5e83 | ||
|
|
94af345ad8 | ||
|
|
6ba43ff52a | ||
|
|
76af0aff0d | ||
|
|
8765aa736e | ||
|
|
097f343303 | ||
|
|
706415fe2c | ||
|
|
de333d6875 | ||
|
|
aac8885512 | ||
|
|
368af701b1 | ||
|
|
22bebbaf3b | ||
|
|
52855eb1a1 | ||
|
|
01e4fc6c5a | ||
|
|
c71667e1c5 | ||
|
|
17485a5cab | ||
|
|
7740c273f6 | ||
|
|
352808ca07 | ||
|
|
36e4cc411f | ||
|
|
8a316ea26d | ||
|
|
50d10ea7bb | ||
|
|
01cdc7576f | ||
|
|
acf333bb66 | ||
|
|
e6bc357d47 | ||
|
|
ef956fc82b | ||
|
|
f401c66e81 | ||
|
|
ab3130360f | ||
|
|
fb94121b35 | ||
|
|
f13cbc3453 | ||
|
|
f1692cb412 | ||
|
|
0a164e08fb | ||
|
|
05306b99a3 | ||
|
|
949ee43f71 | ||
|
|
2fcea71778 | ||
|
|
5f36349c18 | ||
|
|
e4f760ee8b | ||
|
|
068c5ad3ad | ||
|
|
407a92739f | ||
|
|
d05e9148a7 | ||
|
|
1d8638466c | ||
|
|
305a62c954 | ||
|
|
9134148f4d | ||
|
|
f653dcac36 | ||
|
|
071ea3a495 | ||
|
|
b0abde7d06 | ||
|
|
5658a36627 | ||
|
|
c60ad8729f | ||
|
|
0562e3bef7 | ||
|
|
9bd93c62f8 | ||
|
|
bdb1b0ecd1 | ||
|
|
4266baf222 | ||
|
|
8d0c5feccb |
No files matched your search
@@ -4,12 +4,14 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
jobs:
|
||||
@@ -29,7 +31,8 @@ jobs:
|
||||
import sys, yaml
|
||||
|
||||
with open("action.yml", "r", encoding="utf-8") as handle:
|
||||
doc = yaml.safe_load(handle)
|
||||
action_raw = handle.read()
|
||||
doc = yaml.safe_load(action_raw)
|
||||
|
||||
assert isinstance(doc, dict), "action.yml must be a mapping"
|
||||
assert doc.get("name"), "action must declare a name"
|
||||
@@ -65,6 +68,19 @@ jobs:
|
||||
assert "--skip-permissions-unsafe" not in serialized, \
|
||||
"action must never pass --skip-permissions-unsafe"
|
||||
|
||||
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
|
||||
assert run_zero is not None, "action.yml must contain a Run ZERO step"
|
||||
run_zero_script = run_zero.get("run") or ""
|
||||
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
|
||||
"Run ZERO must export the provider key for the current step"
|
||||
assert "later steps inherit" not in action_raw.lower(), \
|
||||
"provider keys must not be documented as inherited by later steps"
|
||||
for line in run_zero_script.splitlines():
|
||||
if "GITHUB_ENV" in line and (
|
||||
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
|
||||
):
|
||||
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
|
||||
|
||||
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
|
||||
PY
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
|
||||
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
||||
# lint` before opening a PR" — these targets back those instructions.
|
||||
.DEFAULT_GOAL := build
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
|
||||
|
||||
@@ -59,20 +59,24 @@ Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||
that fetches the Zero binary is skipped and the first run fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
Install with Bun, then fetch the binary in one of two ways:
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# Option A: run the installer manually
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# Option B: allow the postinstall to run on install
|
||||
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
|
||||
bun add @gitlawb/zero
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
|
||||
global install path, or prefer the install scripts below.
|
||||
Alternatives: allow the postinstall up front by adding
|
||||
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
|
||||
before `bun add`, or run the installer manually
|
||||
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
|
||||
that do not have `bun pm trust`.
|
||||
|
||||
### Install scripts
|
||||
|
||||
|
||||
+7
-7
@@ -44,19 +44,19 @@ npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文
|
||||
|
||||
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
|
||||
|
||||
使用 Bun 安装后,通过以下两种方式之一获取二进制文件:
|
||||
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
|
||||
|
||||
```bash
|
||||
# 选项 A:手动运行安装程序
|
||||
# 项目安装
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# 选项 B:允许 postinstall 在安装时运行
|
||||
# 添加到你的 package.json: "trustedDependencies": ["@gitlawb/zero"]
|
||||
bun add @gitlawb/zero
|
||||
# 全局安装
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
对于全局安装(`bun add -g @gitlawb/zero`),请对全局安装路径使用选项 A,或使用下面的安装脚本。
|
||||
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
|
||||
+4
-5
@@ -27,14 +27,14 @@ inputs:
|
||||
api-key:
|
||||
description: >-
|
||||
The provider API key. Pass it from a repository or organization secret;
|
||||
it is exported into the step environment and never written to the log.
|
||||
it is exported only for the ZERO step and never written to the log.
|
||||
required: false
|
||||
default: ""
|
||||
api-key-env:
|
||||
description: >-
|
||||
Name of the environment variable the chosen provider reads its key from
|
||||
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
|
||||
api-key, that variable is exported for the run. Leave the action
|
||||
api-key, that variable is exported only for the ZERO step. Leave the action
|
||||
provider-agnostic by passing the env name your provider expects rather
|
||||
than hardcoding one here.
|
||||
required: false
|
||||
@@ -192,10 +192,9 @@ runs:
|
||||
echo "::error::api-key-env must be a valid environment variable name." >&2
|
||||
exit 2
|
||||
fi
|
||||
# Export in-process so the zero invocation in this same run block sees
|
||||
# it, and append to GITHUB_ENV so any later steps inherit it too.
|
||||
# Export in-process for this Run ZERO step so zero can read it; do
|
||||
# not persist provider credentials to later job steps.
|
||||
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
|
||||
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
|
||||
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
|
||||
|
||||
+5
-3
@@ -66,10 +66,12 @@ if (!existsSync(nativePath)) {
|
||||
'\n' +
|
||||
(ranByBun
|
||||
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||
'by default. To let it run on future installs, add zero to your\n' +
|
||||
'project\'s trustedDependencies:\n' +
|
||||
'by default. Trust the package to run the blocked postinstall:\n' +
|
||||
' bun pm trust @gitlawb/zero (project install)\n' +
|
||||
' bun pm -g trust @gitlawb/zero (global install)\n' +
|
||||
'On Bun versions without `bun pm trust`, add\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'then reinstall.\n' +
|
||||
'to your project package.json and reinstall.\n' +
|
||||
'\n'
|
||||
: '') +
|
||||
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||
|
||||
@@ -41,8 +41,9 @@ jobs:
|
||||
> provider-neutral: `api-key-env` is the environment variable name the chosen
|
||||
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
|
||||
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
|
||||
> that variable for the run and never prints it. If your repository already
|
||||
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
|
||||
> that variable only for the ZERO step and never prints it. If your repository
|
||||
> already commits a `.zero/config.json` with an active provider, you can omit
|
||||
> `provider`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -51,8 +52,8 @@ jobs:
|
||||
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
|
||||
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
|
||||
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
|
||||
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
|
||||
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
|
||||
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
|
||||
@@ -162,9 +163,10 @@ jobs:
|
||||
## Security notes
|
||||
|
||||
- **Secrets are passed as Action secrets and never logged.** The action exports
|
||||
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
|
||||
environment only. They are never echoed, and ZERO redacts secret-shaped
|
||||
strings from its own output.
|
||||
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
|
||||
is passed only to the Slack post step. They are never echoed or persisted to
|
||||
later workflow steps, and ZERO redacts secret-shaped strings from its own
|
||||
output.
|
||||
- **The sandbox is always active.** This action never passes
|
||||
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
|
||||
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
|
||||
|
||||
+19
-6
@@ -31,15 +31,24 @@ dependencies (only the installing project's own scripts), so the `postinstall`
|
||||
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
To install with Bun, either run the installer manually after installing:
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
Or allow the postinstall to run by adding the package to your project's
|
||||
`trustedDependencies` before installing:
|
||||
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
|
||||
you want to inspect before trusting.
|
||||
|
||||
Alternatively, allow the postinstall to run at install time by adding the
|
||||
package to your project's `trustedDependencies` before installing:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -51,8 +60,12 @@ Or allow the postinstall to run by adding the package to your project's
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
|
||||
against the global install path, or use the install scripts below.
|
||||
On Bun versions that do not have `bun pm trust`, run the installer manually
|
||||
after installing:
|
||||
|
||||
```bash
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
```
|
||||
|
||||
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||
// omits (or malforms) additional_permissions can never succeed: the same
|
||||
// validation runs again at grant time regardless of the user's decision. It
|
||||
// must be rejected as a plain tool error BEFORE any permission prompt, not
|
||||
// surfaced as a confusing "approved but denied" prompt outcome.
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The mismatched-flag case (additional_permissions present without the
|
||||
// sandbox_permissions flag) must be rejected the same way.
|
||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The rejection message must show a concrete, correctly-shaped example: a
|
||||
// model that gets this wrong once (attaching sandbox_permissions out of habit
|
||||
// from an earlier call, without a valid payload) needs enough in the error to
|
||||
// self-correct on retry instead of repeating the identical mistake.
|
||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
||||
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed additional_permissions payload must still reach the normal
|
||||
// permission prompt: this fix only rejects malformed payloads.
|
||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
|
||||
`"additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionDeny}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if promptCalls != 1 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,17 @@ import (
|
||||
// the agent loop owns the provider wiring.
|
||||
|
||||
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
|
||||
// when the caller does not specify a preserve count.
|
||||
const defaultCompactionPreserveLast = 8
|
||||
// when the caller does not specify a preserve count. Kept in sync with the replay
|
||||
// reconstruction default in internal/sessions/replay.go.
|
||||
const defaultCompactionPreserveLast = 6
|
||||
|
||||
// compactionTriggerRatio is the fraction of the context window at which
|
||||
// proactive compaction kicks in (top of each turn).
|
||||
const compactionTriggerRatio = 0.8
|
||||
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
|
||||
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
|
||||
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
|
||||
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
|
||||
// summarization, raise it back toward 0.8.
|
||||
const compactionTriggerRatio = 0.7
|
||||
|
||||
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
||||
// transcript (and so tests can assert on it).
|
||||
@@ -320,6 +325,43 @@ type compactionState struct {
|
||||
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
// ~15-20%, which would trip compaction early (at ~60% of true capacity). It
|
||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||
calibrationRatio float64
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
// running calibration ratio. A single sample is clamped to a sane band so an
|
||||
// outlier (a huge cache-read turn, a provider-overhead spike) can't skew the
|
||||
// estimate enough to disable or thrash compaction.
|
||||
func (state *compactionState) calibrate(rawEstimate int, actualPromptTokens int) {
|
||||
if !state.enabled || rawEstimate <= 0 || actualPromptTokens <= 0 {
|
||||
return
|
||||
}
|
||||
sample := float64(actualPromptTokens) / float64(rawEstimate)
|
||||
if sample < 0.5 {
|
||||
sample = 0.5
|
||||
} else if sample > 2.0 {
|
||||
sample = 2.0
|
||||
}
|
||||
if state.calibrationRatio <= 0 {
|
||||
state.calibrationRatio = 1.0
|
||||
}
|
||||
const alpha = 0.3 // weight on the newest sample; smooths jitter across turns
|
||||
state.calibrationRatio = state.calibrationRatio*(1-alpha) + sample*alpha
|
||||
}
|
||||
|
||||
// calibratedTokens applies the learned ratio to a raw estimate. Before any sample
|
||||
// arrives (ratio unset) it returns the raw estimate unchanged.
|
||||
func (state *compactionState) calibratedTokens(raw int) int {
|
||||
if state.calibrationRatio <= 0 {
|
||||
return raw
|
||||
}
|
||||
return int(float64(raw) * state.calibrationRatio)
|
||||
}
|
||||
|
||||
func newCompactionState(options Options) *compactionState {
|
||||
@@ -348,7 +390,7 @@ func (state *compactionState) maybeCompact(
|
||||
// the messages; both the threshold check and the shrink check below use the
|
||||
// same term so they stay consistent.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
size := estimateTokens(messages) + toolTokens
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if size <= state.threshold {
|
||||
return messages
|
||||
}
|
||||
@@ -365,7 +407,7 @@ func (state *compactionState) maybeCompact(
|
||||
// entirely and preserve recent turns verbatim.
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = estimateTokens(messages) + toolTokens
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if size <= state.threshold {
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
@@ -381,7 +423,7 @@ func (state *compactionState) maybeCompact(
|
||||
// later turn) can try again; we never drop messages on failure here.
|
||||
return messages
|
||||
}
|
||||
newSize := estimateTokens(compacted) + toolTokens
|
||||
newSize := state.calibratedTokens(estimateTokens(compacted) + toolTokens)
|
||||
if newSize >= size {
|
||||
// Compaction did not actually shrink anything (e.g. nothing to
|
||||
// summarize). Leave the history untouched and don't churn next turn.
|
||||
@@ -439,7 +481,7 @@ func (state *compactionState) recover(
|
||||
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
|
||||
// proactive shrink-guard compares like with like.
|
||||
state.reactiveAttempted = true
|
||||
state.lowWaterMark = estimateTokens(result) + estimateToolDefTokens(tools)
|
||||
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCalibrationConvergesTowardActual(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
|
||||
// Before any sample, the estimate is unchanged.
|
||||
if got := state.calibratedTokens(1000); got != 1000 {
|
||||
t.Fatalf("uncalibrated tokens = %d, want 1000", got)
|
||||
}
|
||||
|
||||
// The estimator over-counts: our raw estimate is 1000 but the provider reports
|
||||
// 850 (a 0.85 ratio). Feeding that sample repeatedly should pull the ratio
|
||||
// toward 0.85, so a 1000-token raw estimate calibrates downward.
|
||||
for range 20 {
|
||||
state.calibrate(1000, 850)
|
||||
}
|
||||
got := state.calibratedTokens(1000)
|
||||
if got >= 1000 || got < 820 || got > 880 {
|
||||
t.Fatalf("calibrated tokens = %d, want ~850 after convergence", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrateIgnoresDegenerateSamples(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
state.calibrate(0, 500) // zero estimate
|
||||
state.calibrate(500, 0) // zero actual
|
||||
if state.calibrationRatio != 0 {
|
||||
t.Fatalf("degenerate samples must not move the ratio, got %v", state.calibrationRatio)
|
||||
}
|
||||
// Disabled state never calibrates.
|
||||
disabled := &compactionState{enabled: false}
|
||||
disabled.calibrate(1000, 850)
|
||||
if disabled.calibrationRatio != 0 {
|
||||
t.Fatalf("disabled compaction must not calibrate, got %v", disabled.calibrationRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrateClampsOutliers(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
// A wild outlier (10x) is clamped to 2.0, so one bad sample can't blow up the
|
||||
// ratio and disable compaction.
|
||||
for range 50 {
|
||||
state.calibrate(1000, 10000)
|
||||
}
|
||||
if state.calibrationRatio > 2.0 {
|
||||
t.Fatalf("ratio should be clamped at 2.0, got %v", state.calibrationRatio)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,16 @@ const (
|
||||
toolNameUpdatePlan = "update_plan"
|
||||
toolNameToolSearch = "tool_search"
|
||||
toolNameSkill = "skill"
|
||||
toolNameWriteFile = "write_file"
|
||||
toolNameEditFile = "edit_file"
|
||||
)
|
||||
|
||||
// maxRecentEdits caps how many edited files are carried across a compaction, and
|
||||
// maxEditNoteBytes caps each edit's one-line note, so a long editing run can't
|
||||
// bloat the preserved-state block.
|
||||
const (
|
||||
maxRecentEdits = 20
|
||||
maxEditNoteBytes = 160
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -100,12 +110,123 @@ func formatPlanArguments(arguments string) string {
|
||||
}
|
||||
|
||||
// skillEntry is a named preserved body. It began as loaded-skill state and is
|
||||
// reused for loaded tools and project instruction blocks.
|
||||
// reused for loaded tools, project instruction blocks, and recent file edits.
|
||||
type skillEntry struct {
|
||||
name string
|
||||
body string
|
||||
}
|
||||
|
||||
// recentEdits returns the files mutated by write_file/edit_file calls in messages
|
||||
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||
// changed in each file (from the tool's result) so it need not re-read to
|
||||
// rediscover its own footprint. Capped at maxRecentEdits paths.
|
||||
//
|
||||
// Ordering is by LAST edit, not first: re-editing a file moves it to the newest
|
||||
// position so the tail cap below keeps the file the model most recently touched
|
||||
// rather than an earlier, now-stale entry.
|
||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
pathByID := map[string]string{}
|
||||
sequence := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
for _, call := range message.ToolCalls {
|
||||
if call.Name != toolNameWriteFile && call.Name != toolNameEditFile {
|
||||
continue
|
||||
}
|
||||
path := editPathFromArguments(call.Arguments)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if call.ID != "" {
|
||||
pathByID[call.ID] = path
|
||||
}
|
||||
sequence = append(sequence, path)
|
||||
}
|
||||
}
|
||||
order := lastSeenOrder(sequence)
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
noteByPath := map[string]string{}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||
continue
|
||||
}
|
||||
if path, ok := pathByID[message.ToolCallID]; ok {
|
||||
noteByPath[path] = editNote(message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the most recent maxRecentEdits paths (the tail of last-seen order).
|
||||
if len(order) > maxRecentEdits {
|
||||
order = order[len(order)-maxRecentEdits:]
|
||||
}
|
||||
entries := make([]skillEntry, 0, len(order))
|
||||
for _, path := range order {
|
||||
entries = append(entries, skillEntry{name: path, body: noteByPath[path]})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// lastSeenOrder dedupes paths keeping each at its LAST occurrence, preserving
|
||||
// chronological order otherwise. A re-edited path therefore lands at the newest
|
||||
// position rather than staying pinned to where it first appeared.
|
||||
func lastSeenOrder(paths []string) []string {
|
||||
lastIdx := make(map[string]int, len(paths))
|
||||
for i, p := range paths {
|
||||
lastIdx[p] = i
|
||||
}
|
||||
order := make([]string, 0, len(lastIdx))
|
||||
for i, p := range paths {
|
||||
if lastIdx[p] == i {
|
||||
order = append(order, p)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// editPathFromArguments pulls the target file path from a write_file/edit_file
|
||||
// call's JSON arguments (path/file/file_path/filename aliases). Returns "" on
|
||||
// malformed arguments or no path.
|
||||
func editPathFromArguments(arguments string) string {
|
||||
var parsed struct {
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
FilePath string `json:"file_path"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{parsed.Path, parsed.File, parsed.FilePath, parsed.Filename} {
|
||||
if s := strings.TrimSpace(candidate); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// editNote reduces an edit tool's result to a single short line (its first
|
||||
// non-empty line, byte-capped on a rune boundary) for the preserved summary.
|
||||
func editNote(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
s := strings.TrimSpace(line)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if len(s) > maxEditNoteBytes {
|
||||
limit := maxEditNoteBytes
|
||||
for limit > 0 && !utf8.RuneStart(s[limit]) {
|
||||
limit--
|
||||
}
|
||||
s = s[:limit] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadedSkills returns the skills loaded via the skill tool in messages — the
|
||||
// latest body per name, in first-seen order — matching each skill tool call to
|
||||
// its tool result by id.
|
||||
@@ -310,11 +431,17 @@ func capBody(body string) string {
|
||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||
type preservedState struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||
Tools []preservedTool `json:"tools,omitempty"`
|
||||
Skills []preservedSkill `json:"skills,omitempty"`
|
||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
Path string `json:"path"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type preservedTool struct {
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
@@ -347,6 +474,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
plan = priorState.Plan
|
||||
}
|
||||
|
||||
// Recent edits: merge edits preserved earlier with fresh write_file/edit_file
|
||||
// results in middle (newer note per path wins AND moves to the newest
|
||||
// position), so the tail cap keeps the files the model most recently touched
|
||||
// across repeated compactions rather than dropping a just-re-edited file.
|
||||
edits := capRecentEdits(mergeRecentEdits(preservedEditsToEntries(priorState.RecentEdits), recentEdits(middle)))
|
||||
|
||||
// Tools: preserve deferred tool_search schemas from the transcript. Fresh
|
||||
// loads override older carried copies by name.
|
||||
tools := mergeSkillEntries(preservedToolsToEntries(priorState.Tools), loadedToolSchemas(middle))
|
||||
@@ -360,19 +493,76 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
projectInstructionEntries(middle),
|
||||
)
|
||||
|
||||
if block := formatPreservedState(plan, tools, skills, instructions); block != "" {
|
||||
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
|
||||
summary += "\n\n" + block
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
||||
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
||||
// original slot), a path touched again by a fresh edit MOVES to the newest
|
||||
// position and takes the fresh note, so capRecentEdits — which keeps only the
|
||||
// most-recent tail — never drops a file the model just re-edited. Paths present
|
||||
// only in the older set keep their relative order, ahead of the fresh ones.
|
||||
func mergeRecentEdits(older, newer []skillEntry) []skillEntry {
|
||||
if len(newer) == 0 {
|
||||
return older
|
||||
}
|
||||
freshBody := make(map[string]string, len(newer))
|
||||
freshOrder := make([]string, 0, len(newer))
|
||||
for _, e := range newer {
|
||||
if _, ok := freshBody[e.name]; !ok {
|
||||
freshOrder = append(freshOrder, e.name)
|
||||
}
|
||||
freshBody[e.name] = e.body
|
||||
}
|
||||
merged := make([]skillEntry, 0, len(older)+len(freshOrder))
|
||||
// Older entries that were NOT re-edited keep their position, oldest first.
|
||||
for _, e := range older {
|
||||
if _, refreshed := freshBody[e.name]; refreshed {
|
||||
continue // re-appended at its newest position below
|
||||
}
|
||||
merged = append(merged, e)
|
||||
}
|
||||
// Fresh edits follow, in last-seen order, each carrying the fresh note — so
|
||||
// the most recently touched files sit at the tail the cap preserves.
|
||||
for _, name := range freshOrder {
|
||||
merged = append(merged, skillEntry{name: name, body: freshBody[name]})
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// capRecentEdits bounds the preserved edit list to the most recent maxRecentEdits
|
||||
// entries after a merge, so repeated compactions can't grow it without limit.
|
||||
func capRecentEdits(entries []skillEntry) []skillEntry {
|
||||
if len(entries) > maxRecentEdits {
|
||||
return entries[len(entries)-maxRecentEdits:]
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
||||
entries := make([]skillEntry, 0, len(edits))
|
||||
for _, e := range edits {
|
||||
if e.Path == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, skillEntry{name: e.Path, body: e.Note})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// formatPreservedState renders state as the labelled, single-line
|
||||
// JSON block. Returns "" when there is nothing to preserve.
|
||||
func formatPreservedState(plan string, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
return ""
|
||||
}
|
||||
state := preservedState{Plan: plan}
|
||||
for _, e := range edits {
|
||||
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||
}
|
||||
for _, t := range tools {
|
||||
state.Tools = append(state.Tools, preservedTool{Name: t.name, Body: t.body})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// recentEdits extracts each mutated file's path and a one-line note from the
|
||||
// matching tool result, latest note per path in last-seen order.
|
||||
func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e1", Name: "write_file", Arguments: `{"path":"internal/foo.go","content":"package foo"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote internal/foo.go (12 lines)"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
||||
}
|
||||
|
||||
edits := recentEdits(messages)
|
||||
if len(edits) != 2 {
|
||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||
}
|
||||
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||
}
|
||||
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
||||
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
||||
}
|
||||
}
|
||||
|
||||
// After compaction elides the editing turns, the preserved-state block still
|
||||
// names the edited files and what changed, so the model needn't re-read them.
|
||||
func TestCompactionPreservesRecentEdits(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "add a flag"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e1", Name: "write_file", Arguments: `{"path":"cmd/main.go","content":"..."}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote cmd/main.go (adds --version flag)"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||
}
|
||||
summary := compactStateConversation(t, messages)
|
||||
|
||||
state := parsePreservedStateBlock(summary)
|
||||
if len(state.RecentEdits) != 1 {
|
||||
t.Fatalf("expected 1 preserved edit, got %#v", state.RecentEdits)
|
||||
}
|
||||
if state.RecentEdits[0].Path != "cmd/main.go" || !strings.Contains(state.RecentEdits[0].Note, "--version") {
|
||||
t.Fatalf("preserved edit = %#v, want cmd/main.go + its note", state.RecentEdits[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh edit note for a path overrides the one carried from an earlier
|
||||
// compaction (newer wins) and moves the path to the newest position rather than
|
||||
// duplicating it or leaving it pinned to its old slot.
|
||||
func TestRecentEditsMergeNewerWins(t *testing.T) {
|
||||
prior := preservedState{RecentEdits: []preservedEdit{{Path: "a.go", Note: "old note"}, {Path: "z.go", Note: "z"}}}
|
||||
older := preservedEditsToEntries(prior.RecentEdits)
|
||||
newer := []skillEntry{{name: "a.go", body: "new note"}, {name: "b.go", body: "added"}}
|
||||
|
||||
merged := mergeRecentEdits(older, newer)
|
||||
// z.go (untouched) keeps its lead; a.go is re-edited so it takes the new note
|
||||
// and moves behind z.go, ahead of the brand-new b.go: [z.go, a.go, b.go].
|
||||
if len(merged) != 3 {
|
||||
t.Fatalf("expected z.go + refreshed a.go + b.go, got %#v", merged)
|
||||
}
|
||||
if merged[0].name != "z.go" {
|
||||
t.Fatalf("untouched z.go should stay first, got %#v", merged)
|
||||
}
|
||||
if merged[1].name != "a.go" || merged[1].body != "new note" {
|
||||
t.Fatalf("re-edited a.go should move to the newest edits with the new note, got %#v", merged[1])
|
||||
}
|
||||
if merged[2].name != "b.go" {
|
||||
t.Fatalf("brand-new b.go should be last, got %#v", merged[2])
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: once more than maxRecentEdits distinct files are tracked, re-editing
|
||||
// an early file must move it into the capped tail rather than leaving it to be
|
||||
// dropped — otherwise the next compaction omits exactly the file the model most
|
||||
// recently touched.
|
||||
func TestRecentEditsCapKeepsReeditedFile(t *testing.T) {
|
||||
// Earlier compaction preserved exactly maxRecentEdits files, f0 the oldest.
|
||||
older := make([]skillEntry, 0, maxRecentEdits)
|
||||
for i := 0; i < maxRecentEdits; i++ {
|
||||
older = append(older, skillEntry{name: fmt.Sprintf("f%d.go", i), body: "old"})
|
||||
}
|
||||
// Fresh window re-edits the OLDEST file (f0) and adds a brand-new file, so the
|
||||
// merged list exceeds the cap and something must be dropped.
|
||||
newer := []skillEntry{{name: "f0.go", body: "re-edited"}, {name: "fresh.go", body: "new"}}
|
||||
|
||||
merged := capRecentEdits(mergeRecentEdits(older, newer))
|
||||
if len(merged) != maxRecentEdits {
|
||||
t.Fatalf("cap should keep %d entries, got %d", maxRecentEdits, len(merged))
|
||||
}
|
||||
|
||||
var f0 *skillEntry
|
||||
for i := range merged {
|
||||
if merged[i].name == "f0.go" {
|
||||
f0 = &merged[i]
|
||||
}
|
||||
if merged[i].name == "f1.go" {
|
||||
t.Fatalf("f1.go (now the least-recently touched) should have been dropped, got %#v", merged)
|
||||
}
|
||||
}
|
||||
if f0 == nil {
|
||||
t.Fatalf("re-edited f0.go must survive the cap, got %#v", merged)
|
||||
}
|
||||
if f0.body != "re-edited" {
|
||||
t.Fatalf("surviving f0.go should carry the fresh note, got %q", f0.body)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ const (
|
||||
// calls have executed since the last update_plan call.
|
||||
staleToolCallThreshold = 10
|
||||
|
||||
// stalePlanTurnThreshold injects the same one-shot reminder once this many
|
||||
// turns have passed since the last update_plan while plan items are still
|
||||
// pending — the turn-based complement to staleToolCallThreshold, catching a
|
||||
// plan that drifts stale across many turns that each make few tool calls.
|
||||
stalePlanTurnThreshold = 8
|
||||
|
||||
// toolOnlyProgressReminderAt injects a one-shot progress nudge after this
|
||||
// many consecutive turns contain tool calls but no visible assistant text.
|
||||
// It does not stop the run; it tells the model to synthesize what it already
|
||||
@@ -43,8 +49,12 @@ const (
|
||||
toolFailureHintAt = 2
|
||||
// toolFailureStopAt halts the run after a tool fails this many times in a row
|
||||
// with the same error, so NO model (weak or strong) burns turns looping on a
|
||||
// bad call.
|
||||
toolFailureStopAt = 4
|
||||
// bad call. Set to 6 (not 4): a corrective hint fires at toolFailureHintAt (2),
|
||||
// and a model iterating on a genuinely tricky edit can legitimately fail a few
|
||||
// times after the hint while converging — stopping at 4 cut those runs short.
|
||||
// The streak still resets the moment the tool succeeds or hits a different
|
||||
// error, so this only affects true same-error loops.
|
||||
toolFailureStopAt = 6
|
||||
|
||||
// maxContinueNudges bounds how many times the headless completion gate
|
||||
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
||||
@@ -354,8 +364,13 @@ type guardState struct {
|
||||
emptyTurns int
|
||||
totalToolCalls int
|
||||
toolCallsSincePlanUpdate int
|
||||
planEverCalled bool
|
||||
notCalledReminderSent bool
|
||||
// turnsSincePlanUpdate counts turns (not individual tool calls) since the last
|
||||
// update_plan, so a plan that goes stale across many low-tool-call turns is
|
||||
// still caught — the tool-call counter alone can take many turns to trip when
|
||||
// the model makes only one call per turn.
|
||||
turnsSincePlanUpdate int
|
||||
planEverCalled bool
|
||||
notCalledReminderSent bool
|
||||
// staleReminderSent records whether the stale reminder has already fired for
|
||||
// the current stale interval. It is cleared when a plan update opens a new
|
||||
// interval, making the reminder one-shot per interval rather than per turn.
|
||||
@@ -427,11 +442,16 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
state.toolOnlyReminderSent = false
|
||||
}
|
||||
|
||||
// One turn has passed; the plan-update below resets this to 0 when the model
|
||||
// refreshes the plan this turn.
|
||||
state.turnsSincePlanUpdate++
|
||||
|
||||
for _, call := range collected.ToolCalls {
|
||||
state.totalToolCalls++
|
||||
if call.Name == planToolName {
|
||||
state.planEverCalled = true
|
||||
state.toolCallsSincePlanUpdate = 0
|
||||
state.turnsSincePlanUpdate = 0
|
||||
// A fresh plan update opens a new stale interval.
|
||||
state.staleReminderSent = false
|
||||
// Record how many items remain so the completion gate knows whether
|
||||
@@ -485,10 +505,12 @@ func (state *guardState) progressReminder() string {
|
||||
// number of turns completed so far).
|
||||
func (state *guardState) planReminder(turn int) string {
|
||||
// STALE reminder takes priority: a long run without a plan update is the
|
||||
// stronger signal. One-shot per stale interval.
|
||||
if state.planEverCalled &&
|
||||
!state.staleReminderSent &&
|
||||
state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
||||
// stronger signal. Fires on either the tool-call streak OR a turn streak with
|
||||
// pending items (so a plan drifting stale across many low-call turns is caught,
|
||||
// while a fully-completed plan is left alone). One-shot per stale interval.
|
||||
if state.planEverCalled && !state.staleReminderSent &&
|
||||
(state.toolCallsSincePlanUpdate >= staleToolCallThreshold ||
|
||||
(state.turnsSincePlanUpdate >= stalePlanTurnThreshold && state.planItemsPending > 0)) {
|
||||
state.staleReminderSent = true
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
+158
-62
@@ -25,7 +25,12 @@ const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not
|
||||
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
||||
// the no-output case is retried (a partial turn would duplicate), so this is a
|
||||
// safe recovery for a stalled/dead pooled connection.
|
||||
const maxStreamStallRetries = 2
|
||||
//
|
||||
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
|
||||
// (~5min) before the stall is even detected, so 2 retries left an interactive
|
||||
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
|
||||
// while bounding the worst case to ~2× the idle timeout.
|
||||
const maxStreamStallRetries = 1
|
||||
|
||||
const (
|
||||
toolResultMetaControl = "control"
|
||||
@@ -145,6 +150,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// has already been demanded this run, so it fires at most once.
|
||||
acceptanceRequested := false
|
||||
|
||||
// toolDefCache memoizes each tool's rendered JSON-schema definition across
|
||||
// turns (a tool's advertised schema is stable for the run), so partitionTools
|
||||
// doesn't re-run the recursive schema→map conversion for every tool every turn.
|
||||
toolDefCache := map[string]zeroruntime.ToolDefinition{}
|
||||
|
||||
result := Result{Messages: copyMessages(messages)}
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
@@ -153,7 +163,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// the tool-definition tokens (they ride on every request) in its estimate.
|
||||
// partitionTools depends only on registry/permissions/options/loaded, not on
|
||||
// the messages, so computing it before compaction is safe.
|
||||
exposed, _ := partitionTools(registry, permissionMode, options, loaded)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
@@ -340,6 +350,16 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
// Calibrate the compaction token estimator against the provider's real
|
||||
// prompt-token count for the request we just sent, so later turns trigger
|
||||
// compaction near true capacity instead of ~15% early on code-heavy history.
|
||||
// Recompute the estimate HERE (not at request-build time): a reactive
|
||||
// compaction may have replaced `messages` with a smaller set and re-sent, so
|
||||
// this reflects the request that actually produced collected.Usage. The
|
||||
// assistant reply is appended below, after this, so `messages` is still the
|
||||
// sent request.
|
||||
compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens)
|
||||
|
||||
// Carry the turn's terminal stop reason so a final answer cut off at the
|
||||
// output token cap (or by a content filter) is reported as truncated. A
|
||||
// tool-call turn normalizes to "" and clears any prior reason.
|
||||
@@ -816,6 +836,25 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}
|
||||
}
|
||||
|
||||
// A shell command that sets sandbox_permissions: with_additional_permissions
|
||||
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
|
||||
// is the single source of truth for that shape, and buildPermissionEvent (below)
|
||||
// calls it again to render the prompt's scope text. Check it here, before any
|
||||
// permission prompt is shown: a malformed payload can never be satisfied no
|
||||
// matter what the user decides, so surfacing it as a plain tool error (which the
|
||||
// model can see and retry) is both clearer and avoids presenting a normal-looking
|
||||
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
|
||||
if toolFound && isShellCommandTool(call.Name) {
|
||||
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ask_user is intercepted in the loop (like permissions) so the question can
|
||||
// be routed to an interactive front-end instead of blocking inside the tool.
|
||||
// When no front-end is wired up it degrades to the tool's own graceful Run().
|
||||
@@ -2322,7 +2361,12 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
}
|
||||
raw, ok := args["additional_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
|
||||
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
|
||||
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
|
||||
"omit sandbox_permissions entirely and retry",
|
||||
tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
@@ -2337,7 +2381,9 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
return sandbox.RequestPermissionProfile{}, true, err
|
||||
}
|
||||
if normalized.Empty() {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"additional_permissions must include at least one of network or file_system, for example " +
|
||||
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
@@ -2436,6 +2482,17 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
|
||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||
// so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
|
||||
// partitionToolsCached is partitionTools with an optional per-tool definition
|
||||
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
|
||||
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
|
||||
// swarm tools un-defer once a swarm is active). Only the expensive part —
|
||||
// rendering each tool's JSON-schema parameters — is memoized by tool name, since a
|
||||
// tool's advertised name/description/schema is stable for the run. defCache nil
|
||||
// disables caching (used by tests and the plain partitionTools entrypoint).
|
||||
func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool, defCache map[string]zeroruntime.ToolDefinition) ([]zeroruntime.ToolDefinition, string) {
|
||||
registeredTools := registry.All()
|
||||
|
||||
visible := make([]tools.Tool, 0, len(registeredTools))
|
||||
@@ -2470,76 +2527,115 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
|
||||
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
||||
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
exposedNames := make(map[string]bool, len(visible))
|
||||
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
|
||||
// deferral there is no mid-session loading, so this is byte-stable across turns
|
||||
// and byte-identical to the pre-deferral output.
|
||||
if !active {
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
for _, tool := range visible {
|
||||
if tool.Name() == tools.ToolSearchToolName {
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, cachedRuntimeToolDefinition(defCache, tool))
|
||||
}
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
})
|
||||
return definitions, ""
|
||||
}
|
||||
|
||||
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
|
||||
// deferred tool loads mid-session. The tools block is part of the provider's
|
||||
// cached prefix; if a load reorders it, the cache invalidates from that point
|
||||
// through the system prompt and messages — on EVERY load. Previously the whole
|
||||
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
|
||||
// middle and shifted every later definition. Instead we build three append-only
|
||||
// regions:
|
||||
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
|
||||
// 2. tool_search — always present, right after the stable block;
|
||||
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
|
||||
// transition grows the tail instead of inserting into the eager block.
|
||||
// (tool_search's discovery text still shrinks as tools load, so keeping it and
|
||||
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
|
||||
// load; fully stabilizing the loader's own description is a scoped follow-up.)
|
||||
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
loadedTail := make([]zeroruntime.ToolDefinition, 0)
|
||||
var hiddenTools []tools.Tool
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
deferred := tools.IsDeferred(tool)
|
||||
|
||||
if !active {
|
||||
// Inactive: byte-identical to legacy, but tool_search is never advertised.
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue // added explicitly between the eager block and the loaded tail
|
||||
}
|
||||
if tools.IsDeferred(tool) {
|
||||
if loaded[name] {
|
||||
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
|
||||
} else {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Active path.
|
||||
if deferred && !loaded[name] {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
eager = append(eager, cachedRuntimeToolDefinition(defCache, tool))
|
||||
}
|
||||
|
||||
// On the ACTIVE path tool_search is guaranteed runnable (active implies
|
||||
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
|
||||
// EnabledTools allowlist omits it (the operator allowlisted the deferred
|
||||
// tools, not the loader). Expose its full definition whenever it is not
|
||||
// already in the exposed set. This never runs on the inactive path, so the
|
||||
// byte-identical below-threshold output is preserved.
|
||||
discovery := ""
|
||||
if active && len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
if active && !exposedNames[tools.ToolSearchToolName] {
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: description,
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
} else if active && discovery != "" {
|
||||
for i := range definitions {
|
||||
if definitions[i].Name == tools.ToolSearchToolName {
|
||||
definitions[i].Description = discovery
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
sort.Slice(eager, func(left int, right int) bool {
|
||||
return eager[left].Name < eager[right].Name
|
||||
})
|
||||
sort.Slice(loadedTail, func(left int, right int) bool {
|
||||
return loadedTail[left].Name < loadedTail[right].Name
|
||||
})
|
||||
|
||||
discovery := ""
|
||||
if len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
|
||||
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
|
||||
// — even when a non-empty EnabledTools allowlist omits it (the operator
|
||||
// allowlisted the deferred tools, not the loader). It sits right after the stable
|
||||
// eager block and carries the discovery list for still-hidden tools.
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
}
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
|
||||
definitions = append(definitions, eager...)
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: description,
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
definitions = append(definitions, loadedTail...)
|
||||
|
||||
return definitions, discovery
|
||||
}
|
||||
|
||||
// cachedRuntimeToolDefinition returns the tool's rendered definition, reusing a
|
||||
// cached render when defCache holds one for this tool name. A tool's advertised
|
||||
// definition is stable across a run, so caching skips the recursive schema→map
|
||||
// conversion (schemaToRuntimeMap) that would otherwise run for every tool on every
|
||||
// turn. tool_search is excluded by its callers (its description is dynamic), so it
|
||||
// never poisons the cache. A nil cache computes fresh.
|
||||
func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition, tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
if defCache == nil {
|
||||
return runtimeToolDefinition(tool)
|
||||
}
|
||||
if def, ok := defCache[tool.Name()]; ok {
|
||||
return def
|
||||
}
|
||||
def := runtimeToolDefinition(tool)
|
||||
defCache[tool.Name()] = def
|
||||
return def
|
||||
}
|
||||
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
return zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
}
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
// Cache-stability property: loading a deferred tool must APPEND it after the
|
||||
// always-eager tools, not sort it into the middle of them — otherwise the tool
|
||||
// definitions (the provider's cacheable prefix) shift on every load. The old
|
||||
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
|
||||
// "read_file".
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
|
||||
|
||||
pos := func(name string) int {
|
||||
for i := range exposed {
|
||||
if exposed[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
|
||||
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
|
||||
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
|
||||
}
|
||||
// The eager tools must precede the loaded deferred tool (appended, not inserted).
|
||||
if !(readPos < alphaPos && searchPos < alphaPos) {
|
||||
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
|
||||
}
|
||||
// beta was never loaded — it stays hidden.
|
||||
if pos("mcp__srv__beta") != -1 {
|
||||
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// countingSchemaTool records how many times its Parameters() schema is read, so a
|
||||
// test can prove the definition cache avoids re-rendering across turns.
|
||||
type countingSchemaTool struct {
|
||||
name string
|
||||
calls *int
|
||||
}
|
||||
|
||||
func (t countingSchemaTool) Name() string { return t.name }
|
||||
func (t countingSchemaTool) Description() string { return "counts schema reads" }
|
||||
func (t countingSchemaTool) Parameters() tools.Schema {
|
||||
*t.calls++
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false, Properties: map[string]tools.PropertySchema{
|
||||
"x": {Type: "string"},
|
||||
}}
|
||||
}
|
||||
func (t countingSchemaTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||
// output is identical to the uncached path.
|
||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||
calls := 0
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(countingSchemaTool{name: "alpha", calls: &calls})
|
||||
registry.Register(countingSchemaTool{name: "beta", calls: &calls})
|
||||
|
||||
uncached, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
|
||||
base := calls
|
||||
cache := map[string]zeroruntime.ToolDefinition{}
|
||||
first, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
|
||||
rendersAfterFirst := calls - base
|
||||
second, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
|
||||
rendersAfterSecond := calls - base
|
||||
|
||||
if rendersAfterFirst != 2 {
|
||||
t.Fatalf("first cached call should render 2 tools once each, rendered %d times", rendersAfterFirst)
|
||||
}
|
||||
if rendersAfterSecond != 2 {
|
||||
t.Fatalf("second cached call must reuse the cache (no new schema reads), total renders = %d", rendersAfterSecond)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatal("cached calls must return identical definitions")
|
||||
}
|
||||
if !reflect.DeepEqual(first, uncached) {
|
||||
t.Fatal("cached output must match the uncached partitionTools output")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func planUpdateTurn(status string) zeroruntime.CollectedStream {
|
||||
return zeroruntime.CollectedStream{ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: "update_plan", Arguments: `{"plan":[{"content":"step one","status":"` + status + `"}]}`},
|
||||
}}
|
||||
}
|
||||
|
||||
// Turn-based staleness: a plan that goes many turns without an update — while
|
||||
// items are still pending — draws the stale reminder even though few tool calls
|
||||
// have run (the tool-call streak alone would not have tripped).
|
||||
func TestPlanReminderFiresOnTurnStaleness(t *testing.T) {
|
||||
state := newGuardState()
|
||||
state.observeTurn(planUpdateTurn("in_progress"))
|
||||
|
||||
// Text-only turns advance the turn counter without adding tool calls.
|
||||
for range stalePlanTurnThreshold {
|
||||
state.observeTurn(zeroruntime.CollectedStream{Text: "still working…"})
|
||||
}
|
||||
|
||||
if state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
||||
t.Fatalf("precondition: the tool-call trigger must not fire (%d calls)", state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
if got := state.planReminder(stalePlanTurnThreshold + 2); !strings.Contains(got, planStaleReminderMarker) {
|
||||
t.Fatalf("expected a turn-based stale reminder, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A fully-completed plan is NOT stale: no pending items means the turn-based
|
||||
// trigger stays quiet even after many turns, so a finished plan isn't nagged.
|
||||
func TestPlanReminderSkipsTurnStalenessWhenComplete(t *testing.T) {
|
||||
state := newGuardState()
|
||||
state.observeTurn(planUpdateTurn("completed"))
|
||||
|
||||
for range stalePlanTurnThreshold + 3 {
|
||||
state.observeTurn(zeroruntime.CollectedStream{Text: "wrapping up…"})
|
||||
}
|
||||
|
||||
if got := state.planReminder(stalePlanTurnThreshold + 5); got != "" {
|
||||
t.Fatalf("a completed plan must not draw a stale reminder, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan update resets the turn counter, so the stale reminder does not fire
|
||||
// right after a fresh update.
|
||||
func TestPlanUpdateResetsTurnStaleness(t *testing.T) {
|
||||
state := newGuardState()
|
||||
state.observeTurn(planUpdateTurn("in_progress"))
|
||||
for range stalePlanTurnThreshold - 1 {
|
||||
state.observeTurn(zeroruntime.CollectedStream{Text: "working…"})
|
||||
}
|
||||
// Refresh the plan — resets the turn streak.
|
||||
state.observeTurn(planUpdateTurn("in_progress"))
|
||||
if got := state.planReminder(stalePlanTurnThreshold + 1); got != "" {
|
||||
t.Fatalf("a just-refreshed plan must not be stale, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||
// system prompt and the eager tool schemas that ride on EVERY request. They are
|
||||
// set ~10% above the current measured cost, using ApproxTextTokens (the same
|
||||
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
|
||||
// that pushes either past its ceiling must be justified (and the ceiling raised
|
||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||
//
|
||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||
// only advertise in other modes are excluded, matching what a real turn actually
|
||||
// pays.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 2700
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
// Minimal render: a model is set (so the session block renders) but no Cwd, so
|
||||
// the workspace map, project guidelines, and repo map are excluded — this is the
|
||||
// fixed base every session pays regardless of the workspace it runs in.
|
||||
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
|
||||
got := ApproxTextTokens(prompt)
|
||||
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
|
||||
if got > maxBaseSystemPromptTokens {
|
||||
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
got := estimateToolDefTokens(exposed)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||
if got > maxEagerToolSchemaTokens {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -17,10 +19,20 @@ import (
|
||||
// times. We retry ONLY the connect (not a partially-consumed stream), so no
|
||||
// already-forwarded OnText is ever duplicated.
|
||||
const (
|
||||
maxStreamReconnects = 2
|
||||
streamReconnectBase = 500 * time.Millisecond
|
||||
// maxStreamReconnects is how many times the connect is re-issued after a
|
||||
// transient disconnect. 4 (not 2): with jittered exponential backoff this rides
|
||||
// out a multi-second network blip (~0.5s + 1s + 2s + 4s worst case) instead of
|
||||
// dying on a 2s hiccup and re-burning every token on a restart.
|
||||
maxStreamReconnects = 4
|
||||
// streamReconnectMax caps a single backoff so the tail attempts don't wait
|
||||
// minutes on a long outage.
|
||||
streamReconnectMax = 8 * time.Second
|
||||
)
|
||||
|
||||
// streamReconnectBase is the first-retry delay (doubled each subsequent attempt).
|
||||
// A var, not a const, so tests can shrink it to keep the exhaustion path fast.
|
||||
var streamReconnectBase = 500 * time.Millisecond
|
||||
|
||||
// reconnectNotifier is called before each retry with the 1-based attempt number
|
||||
// and the max, so the caller can surface a "Reconnecting N/max" notice. Nil is
|
||||
// fine.
|
||||
@@ -73,7 +85,7 @@ func streamWithReconnect(ctx context.Context, provider Provider, request zerorun
|
||||
if notify != nil {
|
||||
notify(attempt, maxStreamReconnects)
|
||||
}
|
||||
if waitErr := sleepWithContext(ctx, backoffFor(attempt)); waitErr != nil {
|
||||
if waitErr := sleepWithContext(ctx, jitteredBackoff(attempt)); waitErr != nil {
|
||||
return nil, err // ctx cancelled while waiting; surface the original error
|
||||
}
|
||||
stream, err = provider.StreamCompletion(ctx, request)
|
||||
@@ -96,6 +108,19 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
if isContextLimitError(msg) || isImageRejectionError(err) {
|
||||
return false
|
||||
}
|
||||
// HTTP 5xx statuses are non-reconnectable and must be excluded BEFORE the
|
||||
// transport substring match below — otherwise "504 Gateway Timeout" would slip
|
||||
// through on the generic "timeout" needle. 503 already exhausted
|
||||
// providerio.SendWithRetry (retrying is a redundant double-retry); 500/502/504
|
||||
// are non-idempotent by providerio's rule — the completion POST may already
|
||||
// have reached the model before the upstream/gateway gave up, so replaying the
|
||||
// connect risks duplicate billable work. Digit-boundary matched so an
|
||||
// incidental "504" inside a latency/id number is not mistaken for a status.
|
||||
if errhint.HasStatusCode(msg, "500", "502", "503", "504") {
|
||||
return false
|
||||
}
|
||||
// Transport-level disconnects only. A genuine transport failure (EOF, reset,
|
||||
// refused, timeout) means no response was received, which is safe to reconnect.
|
||||
for _, needle := range []string{
|
||||
"eof",
|
||||
"connection reset",
|
||||
@@ -106,8 +131,6 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
"i/o timeout",
|
||||
"503",
|
||||
"502",
|
||||
"server closed",
|
||||
"unexpected end",
|
||||
} {
|
||||
@@ -118,14 +141,31 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
|
||||
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
|
||||
func backoffFor(attempt int) time.Duration {
|
||||
d := streamReconnectBase
|
||||
for i := 1; i < attempt; i++ {
|
||||
if d >= streamReconnectMax {
|
||||
return streamReconnectMax
|
||||
}
|
||||
d *= 2
|
||||
}
|
||||
if d > streamReconnectMax {
|
||||
d = streamReconnectMax
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// jitteredBackoff adds up to 50% random jitter on top of backoffFor so concurrent
|
||||
// runs (swarm members, a cron fleet) that all trip on the same outage don't
|
||||
// reconnect in lockstep and hammer a recovering endpoint. Never shorter than the
|
||||
// deterministic base, so backoff still grows attempt over attempt.
|
||||
func jitteredBackoff(attempt int) time.Duration {
|
||||
base := backoffFor(attempt)
|
||||
return base + time.Duration(rand.Int63n(int64(base/2)+1))
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
@@ -44,6 +44,10 @@ func TestStreamWithReconnectRecoversFromTransientDisconnect(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamWithReconnectGivesUpAfterMax(t *testing.T) {
|
||||
// Shrink the backoff so exhausting all retries stays fast (real base would
|
||||
// sleep ~7.5s across 4 attempts).
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
// Always fails with a disconnect error → exhausts retries and returns it.
|
||||
p := &flakyProvider{failBefore: 99, failErr: errors.New("connection reset by peer")}
|
||||
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
|
||||
@@ -87,7 +91,7 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
disconnects := []string{
|
||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||
"i/o timeout", "503 Service Unavailable", "server closed the connection",
|
||||
"i/o timeout", "server closed the connection", "connection refused",
|
||||
}
|
||||
for _, m := range disconnects {
|
||||
if !shouldReconnect(ctx, errors.New(m)) {
|
||||
@@ -97,6 +101,13 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
notDisconnects := []string{
|
||||
"context length exceeded", "invalid api key", "model not found",
|
||||
"400 bad request: unsupported parameter",
|
||||
// HTTP 5xx statuses are handled by providerio.SendWithRetry (503) or are
|
||||
// non-idempotent (500/502/504); the reconnect path must not double-retry
|
||||
// them. "504 Gateway Timeout" must NOT slip through on the generic "timeout"
|
||||
// transport signal — a gateway timeout is non-idempotent, since the upstream
|
||||
// may have already processed the completion POST before giving up.
|
||||
"503 Service Unavailable", "502 Bad Gateway",
|
||||
"504 Gateway Timeout", "500 Internal Server Error",
|
||||
}
|
||||
for _, m := range notDisconnects {
|
||||
if shouldReconnect(ctx, errors.New(m)) {
|
||||
@@ -112,6 +123,24 @@ func TestBackoffGrows(t *testing.T) {
|
||||
if backoffFor(2) != 2*streamReconnectBase {
|
||||
t.Fatalf("attempt 2 backoff = %v, want %v", backoffFor(2), 2*streamReconnectBase)
|
||||
}
|
||||
// The exponential base is capped so late attempts don't wait minutes.
|
||||
if got := backoffFor(20); got != streamReconnectMax {
|
||||
t.Fatalf("attempt 20 backoff = %v, want cap %v", got, streamReconnectMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJitteredBackoffStaysInBounds(t *testing.T) {
|
||||
// Jitter never drops below the deterministic base and never exceeds base*1.5,
|
||||
// so backoff still grows attempt over attempt while decorrelating retries.
|
||||
for attempt := 1; attempt <= 5; attempt++ {
|
||||
base := backoffFor(attempt)
|
||||
for i := 0; i < 200; i++ {
|
||||
got := jitteredBackoff(attempt)
|
||||
if got < base || got > base+base/2 {
|
||||
t.Fatalf("attempt %d jittered backoff %v out of [%v, %v]", attempt, got, base, base+base/2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectNoticeRoutesThroughReasoning(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
}
|
||||
// The first skill is always listed regardless of budget.
|
||||
if !strings.Contains(got, "- skill-0:") {
|
||||
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsContext(t *testing.T) {
|
||||
if got := skillsContext(Options{}); got != "" {
|
||||
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||
}
|
||||
got := skillsContext(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a conventional-commit message."},
|
||||
{Name: " ", Description: "nameless, should be skipped"},
|
||||
{Name: "reviewer"},
|
||||
}})
|
||||
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
|
||||
t.Fatalf("missing available_skills block: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
|
||||
t.Fatalf("missing commit-writer line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- reviewer\n") {
|
||||
t.Fatalf("reviewer (no description) line missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
|
||||
with := buildSystemPrompt(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a commit message."},
|
||||
}})
|
||||
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
|
||||
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
|
||||
}
|
||||
// Default (no skills) must reproduce the prior prompt: no skills block.
|
||||
without := buildSystemPrompt(Options{})
|
||||
if strings.Contains(without, "<available_skills>") {
|
||||
t.Fatalf("available_skills block must not appear without skills")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -86,6 +87,9 @@ func buildSystemPrompt(options Options) string {
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||
sections = append(sections, skillsBlock)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
@@ -164,6 +168,64 @@ func specialistDelegationContext(options Options) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// skillsContext lists the reusable skills the model can pull in on demand via the
|
||||
// skill tool, so it invokes the right one on the first try instead of guessing a
|
||||
// name, failing, and reading the error. It mirrors specialistDelegationContext:
|
||||
// names + one-line descriptions only (never skill bodies), and it renders nothing
|
||||
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||
// byte-for-byte.
|
||||
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
|
||||
// budget are summarized as a count rather than dropped — the model can still load
|
||||
// any of them by name (an unknown name returns the full list from the skill tool).
|
||||
const skillsContextListBudget = 640
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<available_skills>\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
line := "- " + name
|
||||
if desc := strings.TrimSpace(info.Description); desc != "" {
|
||||
line += ": " + truncateForSkillLine(desc)
|
||||
}
|
||||
line += "\n"
|
||||
// Always list at least one skill; past the budget, summarize the remainder
|
||||
// as a count instead of silently dropping it.
|
||||
if listed > 0 && spent+len(line) > skillsContextListBudget {
|
||||
omitted++
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
spent += len(line)
|
||||
listed++
|
||||
}
|
||||
if omitted > 0 {
|
||||
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
|
||||
}
|
||||
b.WriteString("</available_skills>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 100
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
|
||||
}
|
||||
|
||||
func sessionRuntimeContext(options Options) string {
|
||||
provider := strings.TrimSpace(options.ProviderName)
|
||||
model := strings.TrimSpace(options.Model)
|
||||
@@ -310,9 +372,9 @@ func projectGuidelineLabel(match, gitRoot string) string {
|
||||
|
||||
// findProjectContextFile returns the first matching project guideline file
|
||||
// in dir. Lookup is case-insensitive on the basename and on the actual
|
||||
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
|
||||
// still resolves to its true filename on case-sensitive filesystems — the
|
||||
// returned path is what should appear in the project guidelines label.
|
||||
// filename returned, so a git-tracked file like AGENTS.md still resolves to
|
||||
// its true filename on case-sensitive filesystems — the returned path is what
|
||||
// should appear in the project guidelines label.
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
|
||||
@@ -46,14 +46,10 @@ work.
|
||||
formatting-only edits unless the user asked for them.
|
||||
4. **Verify.** Verify after edits; see the testing gate below. This is
|
||||
mandatory.
|
||||
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
|
||||
summary — never a single throwaway line. Use short headings and bullets: a
|
||||
one-line "what I built/changed"; **Where it lives** — the key files and how they
|
||||
fit together; **How each requirement is met** — walk through each requirement
|
||||
from the request and name the specifics that satisfy it; what validation ran and
|
||||
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
|
||||
the depth to the work — a big build earns several sections, a trivial fix earns
|
||||
a line or two.
|
||||
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
|
||||
line; a substantial change earns a few short bullets covering what changed, where
|
||||
it lives, and what you did and did not verify. Lead with the outcome, and never
|
||||
pad — do not restate the task or narrate steps the user already watched you take.
|
||||
|
||||
## Editing discipline
|
||||
|
||||
@@ -64,7 +60,9 @@ work.
|
||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||
shell or script invocation.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. Match the existing indentation, imports, and idioms.
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import "strings"
|
||||
//
|
||||
// Different model families respond to different framing: the GPT family benefits
|
||||
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance; the
|
||||
// Claude family is already well-aligned with the core prompt and only needs a
|
||||
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
|
||||
// block appended after the core prompt. Classification is by model id (the agent
|
||||
// only has the model string — no registry dependency).
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance. The
|
||||
// Claude family is already aligned with the core prompt and needs no addendum —
|
||||
// comment discipline is universal in the core prompt now, so every family gets it.
|
||||
// modelPromptAddendum returns the family-specific block appended after the core
|
||||
// prompt, or "" when the family is unknown or needs no specialization.
|
||||
// Classification is by model id (the agent only has the model string — no registry
|
||||
// dependency).
|
||||
|
||||
const (
|
||||
familyOpenAI = "openai"
|
||||
@@ -45,9 +47,10 @@ func modelPromptAddendum(model string) string {
|
||||
return openAIPromptAddendum
|
||||
case familyGemini:
|
||||
return geminiPromptAddendum
|
||||
case familyAnthropic:
|
||||
return anthropicPromptAddendum
|
||||
default:
|
||||
// Anthropic (aligned with the core prompt) and unknown families get no
|
||||
// family-specific block; comment discipline now lives in the core prompt
|
||||
// for every model.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -70,8 +73,3 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
</model_guidance>`
|
||||
|
||||
const anthropicPromptAddendum = `<model_guidance>
|
||||
- Do not add code comments unless the user asks or the surrounding code is
|
||||
comment-dense; match the file's existing comment density.
|
||||
</model_guidance>`
|
||||
@@ -29,9 +29,11 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
if !strings.Contains(claude, anthropicPromptAddendum) {
|
||||
t.Fatalf("expected the Anthropic addendum in the claude prompt")
|
||||
if strings.Contains(claude, "<model_guidance>") {
|
||||
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
|
||||
}
|
||||
if strings.Contains(claude, openAIPromptAddendum) {
|
||||
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
|
||||
|
||||
@@ -23,7 +23,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"how each requirement is met",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -128,7 +129,7 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
|
||||
// Git tracks AGENTS.md on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
|
||||
|
||||
@@ -174,6 +174,16 @@ type SpecialistInfo struct {
|
||||
WhenToUse string
|
||||
}
|
||||
|
||||
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
|
||||
// frontmatter description) surfaced to the system prompt so the model can invoke
|
||||
// the right skill with the skill tool on the first try instead of guessing a name
|
||||
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
|
||||
// package needs no dependency on internal/skills.
|
||||
type SkillInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||
@@ -189,6 +199,12 @@ type Options struct {
|
||||
// populated only where the Task tool is actually registered, so an empty slice
|
||||
// (the default) reproduces the previous prompt byte-for-byte.
|
||||
Specialists []SpecialistInfo
|
||||
// Skills lists the reusable skills installed for this run (the default skills
|
||||
// dir merged with any plugin skill roots). When non-empty the system prompt
|
||||
// gains an <available_skills> block naming them so the model loads the right one
|
||||
// via the skill tool on the first try. Empty (the default) reproduces the
|
||||
// previous prompt byte-for-byte.
|
||||
Skills []SkillInfo
|
||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||
// the specialist runtime in later slices.
|
||||
SessionID string
|
||||
|
||||
+25
-4
@@ -80,6 +80,7 @@ type appDeps struct {
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -180,6 +181,7 @@ func defaultAppDeps() appDeps {
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
@@ -383,6 +385,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runSandbox(args[1:], stdout, stderr, deps)
|
||||
case "update":
|
||||
return runUpdate(args[1:], stdout, stderr, deps)
|
||||
case "upgrade":
|
||||
return runUpgrade(args[1:], stdout, stderr, deps)
|
||||
case "worktrees", "worktree":
|
||||
return runWorktrees(args[1:], stdout, stderr, deps)
|
||||
case "verify":
|
||||
@@ -515,6 +519,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.checkUpdate == nil {
|
||||
deps.checkUpdate = defaults.checkUpdate
|
||||
}
|
||||
if deps.applyUpdate == nil {
|
||||
deps.applyUpdate = defaults.applyUpdate
|
||||
}
|
||||
if deps.now == nil {
|
||||
deps.now = defaults.now
|
||||
}
|
||||
@@ -557,8 +564,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
|
||||
// or "providers ARE configured, just none marked active" (e.g. config.json's
|
||||
// activeProvider is blank/stale). In the second case resolved.Providers still
|
||||
// carries the already-normalized list — prefer falling back to one of those
|
||||
// over wiping everything and forcing the user to re-enter credentials they
|
||||
// already saved.
|
||||
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
|
||||
resolved.Provider = usable
|
||||
resolved.ActiveProvider = usable.Name
|
||||
} else {
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
}
|
||||
}
|
||||
userConfigPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
@@ -740,9 +758,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
Setup: tui.SetupOptions{
|
||||
Visible: setupVisible,
|
||||
Required: needsSetup,
|
||||
@@ -1158,7 +1178,8 @@ func profileHasCredential(profile config.ProviderProfile) bool {
|
||||
}
|
||||
|
||||
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
|
||||
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
|
||||
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
|
||||
// provider like Ollama/LM Studio that needs no API key.
|
||||
func baseURLIsLoopback(baseURL string) bool {
|
||||
u := strings.TrimSpace(baseURL)
|
||||
if u == "" {
|
||||
@@ -1173,7 +1194,7 @@ func baseURLIsLoopback(baseURL string) bool {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
return addr.IsLoopback() || addr.IsPrivate()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -205,6 +205,67 @@ func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
var providerProfile config.ProviderProfile
|
||||
fake := &cliFakeProvider{}
|
||||
|
||||
usable := config.ProviderProfile{
|
||||
Name: "work",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-test",
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
// config.json has providers configured but none marked active (e.g. a
|
||||
// blank/stale activeProvider field) — Resolve returns the successfully
|
||||
// normalized providers list alongside ErrNoActiveProvider.
|
||||
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
return fake, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched")
|
||||
}
|
||||
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
|
||||
}
|
||||
if providerProfile.Name != "work" {
|
||||
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -1267,6 +1328,153 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
|
||||
applyResult := update.ApplyResult{
|
||||
Result: update.Result{
|
||||
CurrentVersion: "dev",
|
||||
LatestVersion: "0.2.0",
|
||||
UpdateAvailable: true,
|
||||
},
|
||||
Applied: true,
|
||||
InstallMethod: update.InstallMethodStandalone,
|
||||
BinaryPath: "/usr/local/bin/zero",
|
||||
Message: "updated to 0.2.0",
|
||||
}
|
||||
var gotOptions update.Options
|
||||
deps := appDeps{
|
||||
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
|
||||
gotOptions = options
|
||||
return applyResult, nil
|
||||
},
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run for --apply")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
|
||||
t.Fatalf("unexpected apply text: %q", stdout.String())
|
||||
}
|
||||
if gotOptions.CurrentVersion != "dev" {
|
||||
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Applied bool `json:"applied"`
|
||||
InstallMethod string `json:"installMethod"`
|
||||
BinaryPath string `json:"binaryPath"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
|
||||
t.Fatalf("unexpected apply JSON: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpgradeDefaultsToApply(t *testing.T) {
|
||||
var applyCalled bool
|
||||
deps := appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
applyCalled = true
|
||||
return update.ApplyResult{Message: "already up to date"}, nil
|
||||
},
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run for `zero upgrade`")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !applyCalled {
|
||||
t.Fatal("expected `zero upgrade` to call applyUpdate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
t.Fatal("applyUpdate should not run")
|
||||
return update.ApplyResult{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
|
||||
t.Fatalf("expected --check/--apply usage error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"update", "--apply", "--target", "linux-arm64"},
|
||||
{"upgrade", "--target", "linux-arm64"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
t.Fatal("applyUpdate should not run when --target is combined with --apply")
|
||||
return update.ApplyResult{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
|
||||
t.Fatalf("expected --target/--apply usage error, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateReportsApplyError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
return update.ApplyResult{}, errors.New("download failed")
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "download failed") {
|
||||
t.Fatalf("expected apply error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOutput(t *testing.T, args []string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
+19
-3
@@ -301,12 +301,27 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
|
||||
// auth/callback) and mandatory authorize params (id_token_add_organizations,
|
||||
// codex_cli_simplified_flow, originator) that the generic loopback flow
|
||||
// cannot supply. Route it to the dedicated ChatGPT login so
|
||||
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
|
||||
if strings.EqualFold(provider, "chatgpt") {
|
||||
if parsed.device {
|
||||
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
|
||||
}
|
||||
if len(parsed.scopes) > 0 {
|
||||
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
|
||||
}
|
||||
return runAuthChatGPT(nil, stdout, stderr, deps)
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
status, err := manager.Login(context.Background(), oauth.LoginOptions{
|
||||
Provider: parsed.positional[0],
|
||||
Provider: provider,
|
||||
Device: parsed.device,
|
||||
ExtraScopes: parsed.scopes,
|
||||
})
|
||||
@@ -489,8 +504,9 @@ Commands:
|
||||
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
|
||||
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
|
||||
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
|
||||
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
|
||||
below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
|
||||
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
|
||||
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
|
||||
@@ -156,3 +156,44 @@ func TestRunAuthHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
||||
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
||||
// authorize params), not the generic manager path. The generic login accepts
|
||||
// --device, so a ChatGPT-specific rejection proves the routing took effect.
|
||||
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
|
||||
// without the required extra params, so OpenAI's authorize endpoint rejected it.
|
||||
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
|
||||
}
|
||||
// Case-insensitive provider name should still route.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login ChatGPT --device should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
|
||||
// must not be silently dropped on the ChatGPT path. The Codex client
|
||||
// registration pins a fixed scope set (incl. api.connectors.*), so custom
|
||||
// scopes are rejected up front rather than plumbed through.
|
||||
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --scope should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -508,6 +509,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
CallingSessionID: options.callingSessionID,
|
||||
CallingToolUseID: options.callingToolUseID,
|
||||
@@ -948,6 +950,14 @@ func writeExecProviderError(stdout io.Writer, stderr io.Writer, format execOutpu
|
||||
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
// Append a one-line next step for recognized provider failures (auth /
|
||||
// rate-limit / connectivity / …). The classifier gates on a provider-origin
|
||||
// marker, so non-provider codes (sandbox_error, mcp_error) never draw a hint.
|
||||
if hint := errhint.CLIHint(errors.New(message)); hint != "" {
|
||||
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", hint); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitProvider
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A recognized provider error in text mode prints the raw message plus a one-line
|
||||
// actionable hint; a non-provider error (or JSON mode) prints no hint.
|
||||
func TestWriteExecProviderErrorAppendsHint(t *testing.T) {
|
||||
t.Run("provider auth error gets a hint", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := writeExecProviderError(&stdout, &stderr, execOutputText, "provider_error",
|
||||
"auth error: your API key is missing or invalid")
|
||||
if code != exitProvider {
|
||||
t.Fatalf("exit code = %d, want exitProvider", code)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "auth error:") {
|
||||
t.Fatalf("expected raw message, got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "zero auth") {
|
||||
t.Fatalf("expected an actionable hint referencing `zero auth`, got %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-provider error gets no hint", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeExecProviderError(&stdout, &stderr, execOutputText, "sandbox_error",
|
||||
"sandbox setup failed: permission denied")
|
||||
out := stderr.String()
|
||||
// Exactly one "[zero]" line — no spurious hint attached to a local error.
|
||||
if n := strings.Count(out, "[zero]"); n != 1 {
|
||||
t.Fatalf("expected exactly one [zero] line for a non-provider error, got %d:\n%s", n, out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json mode never appends a hint line", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeExecProviderError(&stdout, &stderr, execOutputJSON, "provider_error",
|
||||
"auth error: bad key")
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("json mode must not write to stderr, got %q", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "zero auth") {
|
||||
t.Fatalf("json mode must not inject a hint into the structured payload, got %q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -57,6 +59,27 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
|
||||
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
|
||||
}
|
||||
|
||||
// skillInfos resolves the reusable skills the model can load via the skill tool —
|
||||
// the default skills dir merged with any plugin skill roots, the same set the
|
||||
// plugin-aware skill tool resolves against — as plain data for the agent's system
|
||||
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
|
||||
// the prompt byte-identical.
|
||||
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
|
||||
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
|
||||
if len(merged) == 0 {
|
||||
return nil
|
||||
}
|
||||
infos := make([]agent.SkillInfo, 0, len(merged))
|
||||
for _, skill := range merged {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
|
||||
// line, prefixing the diagnostic kind and appending the most specific locator
|
||||
// available (manifest path, then plugin path, then plugin ID) so an operator can
|
||||
|
||||
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -453,6 +454,11 @@ func providerProfileIsLocal(profile config.ProviderProfile) bool {
|
||||
case "localhost", "127.0.0.1", "::1":
|
||||
return true
|
||||
}
|
||||
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
|
||||
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
|
||||
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+51
-8
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type updateOptions struct {
|
||||
check bool
|
||||
apply bool
|
||||
json bool
|
||||
repository string
|
||||
endpoint string
|
||||
@@ -20,6 +21,18 @@ type updateOptions struct {
|
||||
}
|
||||
|
||||
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
return runUpdateCommand(args, stdout, stderr, deps, false)
|
||||
}
|
||||
|
||||
func runUpgrade(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
return runUpdateCommand(args, stdout, stderr, deps, true)
|
||||
}
|
||||
|
||||
// runUpdateCommand backs both `zero update` and `zero upgrade`. defaultApply
|
||||
// makes `--apply` the implicit behavior for `zero upgrade` when neither
|
||||
// `--check` nor `--apply` is passed explicitly; `zero update` keeps requiring
|
||||
// one of the two so existing scripts around `zero update --check` don't change.
|
||||
func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, defaultApply bool) int {
|
||||
options, help, err := parseUpdateArgs(args)
|
||||
if err != nil {
|
||||
return writeUsageError(stderr, err.Error())
|
||||
@@ -30,10 +43,19 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if !options.check {
|
||||
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
|
||||
if options.check && options.apply {
|
||||
return writeUsageError(stderr, "Pass only one of --check or --apply.")
|
||||
}
|
||||
checkOptions := update.Options{
|
||||
if !options.check && !options.apply {
|
||||
if !defaultApply {
|
||||
return writeUsageError(stderr, "Pass --check to check for updates or --apply to install one (or use `zero upgrade`).")
|
||||
}
|
||||
options.apply = true
|
||||
}
|
||||
if options.apply && options.target != "" {
|
||||
return writeUsageError(stderr, "--target cannot be combined with --apply; it only verifies release assets for --check.")
|
||||
}
|
||||
updateOptions := update.Options{
|
||||
CurrentVersion: version,
|
||||
Repository: options.repository,
|
||||
Endpoint: options.endpoint,
|
||||
@@ -44,10 +66,26 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
if err != nil {
|
||||
return writeUsageError(stderr, err.Error())
|
||||
}
|
||||
checkOptions.GOOS = target.GOOS
|
||||
checkOptions.GOARCH = target.GOARCH
|
||||
updateOptions.GOOS = target.GOOS
|
||||
updateOptions.GOARCH = target.GOARCH
|
||||
}
|
||||
result, err := deps.checkUpdate(context.Background(), checkOptions)
|
||||
if options.apply {
|
||||
result, err := deps.applyUpdate(context.Background(), updateOptions)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "Could not install update: "+err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, update.FormatApply(result)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
result, err := deps.checkUpdate(context.Background(), updateOptions)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
|
||||
}
|
||||
@@ -72,6 +110,8 @@ func parseUpdateArgs(args []string) (updateOptions, bool, error) {
|
||||
return options, true, nil
|
||||
case arg == "--check":
|
||||
options.check = true
|
||||
case arg == "--apply":
|
||||
options.apply = true
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--repo":
|
||||
@@ -155,14 +195,17 @@ func parseUpdateTarget(value string) (string, error) {
|
||||
func writeUpdateHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero update --check [flags]
|
||||
zero update --apply [flags]
|
||||
zero upgrade [flags]
|
||||
|
||||
Flags:
|
||||
--check Check the latest GitHub release without installing
|
||||
--json Print the update check result as JSON
|
||||
--apply Download, verify, and install the latest release
|
||||
--json Print the update result as JSON
|
||||
--repo <owner/repo> Repository to check when no endpoint is provided
|
||||
--endpoint <url|repo> Release API URL or owner/repo slug to check
|
||||
--timeout <duration> Release check timeout (default 5s)
|
||||
--target <platform> Release target to verify (for example windows-x64)
|
||||
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
|
||||
@@ -662,3 +662,144 @@ func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangesArgsAuto(t *testing.T) {
|
||||
// 1. Correct parsing of auto
|
||||
for _, args := range [][]string{
|
||||
{"--auto"},
|
||||
{"-a"},
|
||||
} {
|
||||
options, help, err := parseChangesArgs(args, "commit")
|
||||
if err != nil {
|
||||
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
|
||||
}
|
||||
if help {
|
||||
t.Fatalf("parseChangesArgs(%v) returned help", args)
|
||||
}
|
||||
if !options.auto {
|
||||
t.Fatalf("auto = false, want true (args %v)", args)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Reject --auto on inspect
|
||||
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
|
||||
if err == nil || !strings.Contains(err.Error(), "--auto") {
|
||||
t.Fatalf("expected --auto rejection on inspect, got %v", err)
|
||||
}
|
||||
|
||||
// 3. Reject both --message and --auto on commit
|
||||
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
||||
t.Fatalf("expected --message and --auto conflict error, got %v", err)
|
||||
}
|
||||
|
||||
// 4. Reject both empty message and --auto on commit
|
||||
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
||||
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type mockCommitMsgProvider struct {
|
||||
response string
|
||||
req zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
p.req = request
|
||||
events := make(chan zeroruntime.StreamEvent, 2)
|
||||
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
|
||||
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(events)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func TestRunChangesCommitAuto(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
summary := zerogit.ChangeSummary{
|
||||
Root: cwd,
|
||||
Branch: "main",
|
||||
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
|
||||
Diff: "some diff content with ghp_SECRETKEYHERE",
|
||||
}
|
||||
|
||||
mockProv := &mockCommitMsgProvider{
|
||||
response: "```\nfeat: auto commit message\n```",
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
commitCalled := false
|
||||
|
||||
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
||||
return summary, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
cfg := execResolvedConfig()
|
||||
cfg.Provider.Name = "openai"
|
||||
cfg.Provider.Model = "gpt-4o"
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return mockProv, nil
|
||||
},
|
||||
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
|
||||
commitCalled = true
|
||||
if options.Message != "feat: auto commit message" {
|
||||
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
|
||||
}
|
||||
return zerogit.CommitResult{
|
||||
Root: cwd,
|
||||
Message: options.Message,
|
||||
Committed: true,
|
||||
CommitHash: "abc1234",
|
||||
Before: summary,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !commitCalled {
|
||||
t.Fatal("expected commitChanges to be called")
|
||||
}
|
||||
// Verify that secret in the diff was redacted
|
||||
promptContent := mockProv.req.Messages[0].Content
|
||||
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
|
||||
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
|
||||
}
|
||||
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
|
||||
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
|
||||
t.Fatalf("expected status message in stdout, got %q", stdout.String())
|
||||
}
|
||||
|
||||
t.Run("EmptyLLMResponse", func(t *testing.T) {
|
||||
mockProvEmpty := &mockCommitMsgProvider{
|
||||
response: " \n\n ",
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
||||
return summary, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return mockProvEmpty, nil
|
||||
},
|
||||
})
|
||||
if code == exitSuccess {
|
||||
t.Fatal("expected command to fail when LLM returns empty response, got success")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "empty commit message") {
|
||||
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/selfverify"
|
||||
"github.com/Gitlawb/zero/internal/testrunner"
|
||||
"github.com/Gitlawb/zero/internal/verify"
|
||||
"github.com/Gitlawb/zero/internal/worktrees"
|
||||
"github.com/Gitlawb/zero/internal/zerogit"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type worktreeCommandOptions struct {
|
||||
@@ -35,8 +38,10 @@ type changesCommandOptions struct {
|
||||
cwd string
|
||||
baseRef string
|
||||
message string
|
||||
hasMessage bool
|
||||
dryRun bool
|
||||
maxDiffBytes int
|
||||
auto bool
|
||||
}
|
||||
|
||||
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
@@ -198,9 +203,52 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
return exitSuccess
|
||||
case "commit":
|
||||
var message string
|
||||
if options.auto {
|
||||
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
|
||||
Cwd: workspaceRoot,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err))
|
||||
}
|
||||
if summary.Clean {
|
||||
return writeExecUsageError(stderr, "no changes to commit")
|
||||
}
|
||||
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err))
|
||||
}
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return writeExecUsageError(stderr, "no provider configured for auto-commit message")
|
||||
}
|
||||
provider, err := deps.newProvider(resolved.Provider)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err))
|
||||
}
|
||||
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
|
||||
safeSummary := redactChangeSummary(summary)
|
||||
genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err))
|
||||
}
|
||||
message = msg
|
||||
} else {
|
||||
message = options.message
|
||||
}
|
||||
|
||||
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Message: options.message,
|
||||
Message: message,
|
||||
DryRun: options.dryRun,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
})
|
||||
@@ -395,9 +443,11 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, err
|
||||
}
|
||||
options.message = value
|
||||
options.hasMessage = true
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--message="):
|
||||
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
|
||||
options.hasMessage = true
|
||||
case arg == "--diff-bytes":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -415,14 +465,19 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, err
|
||||
}
|
||||
options.maxDiffBytes = maxDiffBytes
|
||||
case arg == "-a" || arg == "--auto":
|
||||
options.auto = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||
}
|
||||
}
|
||||
if command != "commit" && (options.message != "" || options.dryRun) {
|
||||
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
|
||||
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
|
||||
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
|
||||
}
|
||||
if command == "commit" && options.hasMessage && options.auto {
|
||||
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
||||
}
|
||||
if command == "commit" && options.baseRef != "" {
|
||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||
@@ -712,9 +767,47 @@ Flags:
|
||||
--base <ref> Diff against <ref>...HEAD instead of the working tree
|
||||
--diff-bytes <n> Maximum diff bytes to include
|
||||
-m, --message <text> Commit message for `+"`zero changes commit`"+`
|
||||
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
|
||||
--dry-run Preview commit metadata without mutating git state
|
||||
--json Print JSON output
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
||||
var promptBuilder strings.Builder
|
||||
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
|
||||
promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n")
|
||||
promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n")
|
||||
promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n")
|
||||
promptBuilder.WriteString("Git Diff:\n")
|
||||
promptBuilder.WriteString(summary.Diff)
|
||||
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
|
||||
},
|
||||
}
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
collected := zeroruntime.CollectStream(ctx, stream)
|
||||
if collected.Error != "" {
|
||||
return "", fmt.Errorf("%s", collected.Error)
|
||||
}
|
||||
|
||||
msg := strings.TrimSpace(collected.Text)
|
||||
if strings.HasPrefix(msg, "```") {
|
||||
if idx := strings.Index(msg, "\n"); idx != -1 {
|
||||
msg = msg[idx+1:]
|
||||
}
|
||||
}
|
||||
msg = strings.TrimSuffix(msg, "```")
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return "", fmt.Errorf("provider returned empty commit message")
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
@@ -130,7 +130,11 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
|
||||
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
// On ErrNoActiveProvider, providers may still hold the successfully
|
||||
// normalized (but active-less) profile list — keep it so a caller can fall
|
||||
// back to an already-configured usable provider instead of treating this
|
||||
// like a config with nothing set up at all.
|
||||
return ResolvedConfig{Providers: providers}, err
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
@@ -144,6 +148,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
}, nil
|
||||
}
|
||||
@@ -225,6 +230,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
|
||||
}
|
||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
@@ -277,6 +283,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
// Local control is intentionally user-config/override only. A cloned project
|
||||
// must not be able to make browser, desktop, or terminal automation tools
|
||||
// appear in the model's tool surface.
|
||||
@@ -665,6 +672,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
cfg.Tools.deferThresholdSet = true
|
||||
}
|
||||
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
||||
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
@@ -700,6 +708,24 @@ func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalContr
|
||||
}
|
||||
}
|
||||
|
||||
func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
|
||||
if src.ToggleDetailed != "" {
|
||||
dst.ToggleDetailed = src.ToggleDetailed
|
||||
}
|
||||
if src.ToggleMouse != "" {
|
||||
dst.ToggleMouse = src.ToggleMouse
|
||||
}
|
||||
if src.CycleReasoning != "" {
|
||||
dst.CycleReasoning = src.CycleReasoning
|
||||
}
|
||||
if src.TogglePlan != "" {
|
||||
dst.TogglePlan = src.TogglePlan
|
||||
}
|
||||
if src.ToggleSidebar != "" {
|
||||
dst.ToggleSidebar = src.ToggleSidebar
|
||||
}
|
||||
}
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
@@ -834,7 +860,11 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
}
|
||||
|
||||
if !activeFound {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
// Return the successfully normalized list alongside the error (rather than
|
||||
// nil) so a caller like the interactive TUI can still fall back to an
|
||||
// already-configured, usable provider instead of forcing a full
|
||||
// re-onboarding wizard just because none was marked active.
|
||||
return normalized, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
}
|
||||
if active.Model == "" {
|
||||
return nil, ProviderProfile{}, &setupFixableError{
|
||||
|
||||
@@ -916,6 +916,25 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||
// Multiple providers configured (e.g. via `zero provider add`) but
|
||||
// activeProvider is blank/stale — a caller like the interactive TUI still
|
||||
// needs the normalized list to fall back to an already-usable provider
|
||||
// instead of forcing a full re-onboarding wizard.
|
||||
path := writeConfig(t, `{"providers":[
|
||||
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||
]}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if !errors.Is(err, ErrNoActiveProvider) {
|
||||
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
|
||||
}
|
||||
if len(resolved.Providers) != 2 {
|
||||
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
|
||||
@@ -112,6 +112,25 @@ func (p PreferencesConfig) RecapsEnabled() bool {
|
||||
return p.Recaps == nil || *p.Recaps
|
||||
}
|
||||
|
||||
// KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI
|
||||
// can remap. An empty string means "use the built-in default" for that action.
|
||||
type KeyBindingDef string
|
||||
|
||||
// KeyBindingsConfig holds the subset of TUI keybindings that users may remap
|
||||
// via config.json. Each field defaults to a sensible built-in when empty.
|
||||
type KeyBindingsConfig struct {
|
||||
// ToggleDetailed toggles the detailed transcript view (default: ctrl+o).
|
||||
ToggleDetailed KeyBindingDef `json:"toggleDetailed,omitempty"`
|
||||
// ToggleMouse toggles mouse capture release (default: ctrl+e).
|
||||
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
|
||||
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
|
||||
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
|
||||
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
|
||||
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
|
||||
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
|
||||
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||
}
|
||||
|
||||
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
|
||||
// Helpers are discovered lazily by the tool that needs them; no setup command or
|
||||
// background probe is required during startup.
|
||||
@@ -206,6 +225,7 @@ type FileConfig struct {
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
|
||||
@@ -220,6 +240,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
@@ -232,6 +253,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
}
|
||||
if !cfg.LocalControl.Empty() {
|
||||
raw.LocalControl = &cfg.LocalControl
|
||||
@@ -256,6 +278,7 @@ type Overrides struct {
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
@@ -270,6 +293,7 @@ type ResolvedConfig struct {
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
@@ -316,6 +340,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Swarm SwarmConfig `json:"swarm"`
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings"`
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
@@ -342,6 +367,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.Tools = raw.Tools
|
||||
cfg.Swarm = raw.Swarm
|
||||
cfg.Preferences = raw.Preferences
|
||||
cfg.KeyBindings = raw.KeyBindings
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package errhint classifies provider/model failures into a few user-actionable
|
||||
// categories and turns them into a one-line "next step" hint.
|
||||
//
|
||||
// Provider errors already arrive with a classified string prefix from
|
||||
// providerio.ClassifiedError ("auth error:", "rate limit error:", …); lower-level
|
||||
// failures (DNS, TLS, timeouts, context-length) arrive as raw driver or library
|
||||
// messages. Classify matches both so the interactive error row (TUI) and the
|
||||
// `zero exec` provider-error path (CLI) can append one concrete next step instead
|
||||
// of dumping an identical red blob for every failure mode.
|
||||
package errhint
|
||||
|
||||
import "strings"
|
||||
|
||||
// Category buckets a provider/model failure into a small set of classes that each
|
||||
// map to a distinct recovery action.
|
||||
type Category int
|
||||
|
||||
const (
|
||||
// Unknown means the error didn't match any known signature; callers should
|
||||
// emit no hint rather than guess.
|
||||
Unknown Category = iota
|
||||
Auth
|
||||
RateLimit
|
||||
Connectivity
|
||||
ModelNotFound
|
||||
ContextOverflow
|
||||
)
|
||||
|
||||
// providerMarkers are the prefixes the provider layer attaches to every
|
||||
// provider-originated failure: providerio.ClassifiedError emits "auth error:",
|
||||
// "rate limit error:", "provider error:", and "provider request error:", and the
|
||||
// streaming paths wrap transport/decoding failures as "provider stream error:".
|
||||
// A UI surface's error can also be a *local* failure (a tool's "permission
|
||||
// denied", a "file does not exist", a config error), so Classify only proceeds
|
||||
// past this gate for messages that are recognizably from the provider — otherwise
|
||||
// a broad substring like "does not exist" would attach a bogus /model hint to an
|
||||
// unrelated local error.
|
||||
var providerMarkers = []string{
|
||||
"auth error:",
|
||||
"rate limit error:",
|
||||
"provider error:",
|
||||
"provider request error:",
|
||||
"provider stream error:",
|
||||
}
|
||||
|
||||
// Classify buckets err by scanning its message for known signatures. It is a
|
||||
// deliberately conservative string heuristic — the provider layer has already
|
||||
// discarded the numeric HTTP status by the time the error reaches a UI surface,
|
||||
// so the message is all we have. It first gates on a provider-origin marker (see
|
||||
// providerMarkers) so local failures never draw a provider hint, then sub-classifies.
|
||||
// Order matters: more specific signatures are tested before broader ones (e.g.
|
||||
// "context length" as overflow before the generic "timeout" as connectivity).
|
||||
func Classify(err error) Category {
|
||||
if err == nil {
|
||||
return Unknown
|
||||
}
|
||||
m := strings.ToLower(err.Error())
|
||||
if !containsAny(m, providerMarkers...) {
|
||||
return Unknown
|
||||
}
|
||||
switch {
|
||||
case containsAny(m, "auth error:", "unauthorized", "api key", "api_key", "invalid_api_key",
|
||||
"authentication", "permission denied", "forbidden") || containsStatusCode(m, "401", "403"):
|
||||
return Auth
|
||||
case containsAny(m, "rate limit", "rate_limit", "too many requests", "quota",
|
||||
"resource_exhausted", "overloaded") || containsStatusCode(m, "429", "529"):
|
||||
return RateLimit
|
||||
case containsAny(m, "context length", "context window", "maximum context", "context_length_exceeded",
|
||||
"too many tokens", "prompt is too long", "reduce the length", "maximum context length"):
|
||||
return ContextOverflow
|
||||
case containsAny(m, "model not found", "model_not_found", "does not exist", "unknown model",
|
||||
"no such model", "unsupported model", "invalid model", "model is not"):
|
||||
return ModelNotFound
|
||||
case containsAny(m, "dial tcp", "no such host", "connection refused", "network is unreachable",
|
||||
"i/o timeout", "context deadline exceeded", "tls handshake", "connection reset",
|
||||
"unexpected eof", "lookup ", "timeout"):
|
||||
return Connectivity
|
||||
default:
|
||||
return Unknown
|
||||
}
|
||||
}
|
||||
|
||||
// TUIHint returns a one-line hint referencing interactive slash commands, or ""
|
||||
// when the category is Unknown. Meant to sit under the raw error in the live
|
||||
// error row.
|
||||
func TUIHint(err error) string {
|
||||
switch Classify(err) {
|
||||
case Auth:
|
||||
return "API key rejected — run /provider to re-check your credentials"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with /model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run /doctor --connectivity"
|
||||
case ModelNotFound:
|
||||
return "Model unavailable — pick another with /model"
|
||||
case ContextOverflow:
|
||||
return "Context window full — run /compact to free space"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// CLIHint returns a one-line hint referencing `zero …` subcommands, or "" when the
|
||||
// category is Unknown. Meant for the non-interactive `zero exec` error path, where
|
||||
// slash commands don't apply.
|
||||
func CLIHint(err error) string {
|
||||
switch Classify(err) {
|
||||
case Auth:
|
||||
return "API key rejected — run `zero auth` or set the provider's API key, then retry"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with --model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run `zero doctor`"
|
||||
case ModelNotFound:
|
||||
return "Model unavailable — run `zero doctor` or pick another with --model"
|
||||
case ContextOverflow:
|
||||
return "Context window full — shorten the prompt or start a fresh session"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(haystack string, needles ...string) bool {
|
||||
for _, n := range needles {
|
||||
if strings.Contains(haystack, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsStatusCode reports whether haystack contains any of the given HTTP
|
||||
// status codes as a standalone number — not embedded in a longer digit run like
|
||||
// "completed in 4290ms" or "request id 14015" — so an incidental number can't be
|
||||
// mis-bucketed as an auth/rate-limit failure.
|
||||
func containsStatusCode(haystack string, codes ...string) bool {
|
||||
return HasStatusCode(haystack, codes...)
|
||||
}
|
||||
|
||||
// HasStatusCode reports whether haystack contains any of the given HTTP status
|
||||
// codes as a standalone number — not embedded in a longer digit run like
|
||||
// "completed in 4290ms" or "request id 14015". Exported so other packages (e.g.
|
||||
// the reconnect classifier) can gate on a status code without re-implementing
|
||||
// the digit-boundary check.
|
||||
func HasStatusCode(haystack string, codes ...string) bool {
|
||||
for _, code := range codes {
|
||||
for from := 0; ; {
|
||||
rel := strings.Index(haystack[from:], code)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
pos := from + rel
|
||||
beforeOK := pos == 0 || !isASCIIDigit(haystack[pos-1])
|
||||
end := pos + len(code)
|
||||
afterOK := end >= len(haystack) || !isASCIIDigit(haystack[end])
|
||||
if beforeOK && afterOK {
|
||||
return true
|
||||
}
|
||||
from = pos + 1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isASCIIDigit(b byte) bool {
|
||||
return b >= '0' && b <= '9'
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package errhint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg string
|
||||
want Category
|
||||
}{
|
||||
{"nil-ish empty", "", Unknown},
|
||||
{"providerio auth prefix", "auth error: your API key is missing or invalid — run `zero auth`", Auth},
|
||||
{"raw 401", "provider request error: 401 Unauthorized", Auth},
|
||||
{"invalid api key", "provider request error: invalid_api_key: incorrect key provided", Auth},
|
||||
{"rate limit prefix", "rate limit error: 429 too many requests", RateLimit},
|
||||
{"overloaded", "provider error: model is overloaded, please retry", RateLimit},
|
||||
{"resource exhausted gemini", "provider stream error: rpc error: code = ResourceExhausted desc = quota exceeded", RateLimit},
|
||||
{"context length openai", "provider request error: this model's maximum context length is 128000 tokens", ContextOverflow},
|
||||
{"context_length_exceeded", "provider request error: context_length_exceeded", ContextOverflow},
|
||||
{"model not found", "provider request error: 404 the model `gpt-9` does not exist", ModelNotFound},
|
||||
{"unknown model", "provider request error: unknown model: sonnet-99", ModelNotFound},
|
||||
{"dns failure", "provider stream error: Post \"https://api...\": dial tcp: lookup api.foo.com: no such host", Connectivity},
|
||||
{"connection refused", "provider stream error: dial tcp 127.0.0.1:443: connection refused", Connectivity},
|
||||
{"deadline", "provider stream error: context deadline exceeded (Client.Timeout exceeded)", Connectivity},
|
||||
{"provider-marked but no sub-signature", "provider error: something totally unexpected happened", Unknown},
|
||||
|
||||
// An incidental number that merely contains a status-code digit run must NOT
|
||||
// be mis-bucketed (digit-boundary check), even behind a provider marker.
|
||||
{"incidental latency number", "provider error: request completed in 4290ms then failed", Unknown},
|
||||
{"incidental request id", "provider error: request id 14015 failed unexpectedly", Unknown},
|
||||
{"standalone 401 still auth", "provider request error: got 401 from upstream", Auth},
|
||||
{"standalone 429 still rate limit", "provider error: 429 returned", RateLimit},
|
||||
|
||||
// Local (non-provider) failures must NOT be classified — no provider marker,
|
||||
// so no bogus recovery hint attaches to them.
|
||||
{"local fs permission denied", "open /etc/shadow: permission denied", Unknown},
|
||||
{"local file missing", "stat foo.go: no such file or directory", Unknown},
|
||||
{"local model config typo", "unknown model: sonnet-99", Unknown},
|
||||
{"tool timeout local", "tool bash timed out after 600s", Unknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Classify(errors.New(tc.msg)); got != tc.want {
|
||||
t.Fatalf("Classify(%q) = %v, want %v", tc.msg, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyNil(t *testing.T) {
|
||||
if got := Classify(nil); got != Unknown {
|
||||
t.Fatalf("Classify(nil) = %v, want Unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Context-overflow must win over the connectivity "timeout" catch-all: a message
|
||||
// mentioning both "context length" and a timeout is an overflow, not a network
|
||||
// problem.
|
||||
func TestContextOverflowBeatsConnectivity(t *testing.T) {
|
||||
err := errors.New("provider request error: maximum context length exceeded; request timeout")
|
||||
if got := Classify(err); got != ContextOverflow {
|
||||
t.Fatalf("Classify = %v, want ContextOverflow", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHintsPresentForKnownCategories(t *testing.T) {
|
||||
known := []error{
|
||||
errors.New("auth error: bad key"),
|
||||
errors.New("rate limit error: 429"),
|
||||
errors.New("provider stream error: dial tcp: no such host"),
|
||||
errors.New("provider request error: model does not exist"),
|
||||
errors.New("provider request error: maximum context length is 128000 tokens"),
|
||||
}
|
||||
for _, err := range known {
|
||||
if h := TUIHint(err); strings.TrimSpace(h) == "" {
|
||||
t.Fatalf("TUIHint(%q) empty, want a hint", err)
|
||||
}
|
||||
if h := CLIHint(err); strings.TrimSpace(h) == "" {
|
||||
t.Fatalf("CLIHint(%q) empty, want a hint", err)
|
||||
}
|
||||
}
|
||||
// Unknown yields no hint on either surface.
|
||||
unknown := errors.New("provider error: mystery")
|
||||
if h := TUIHint(unknown); h != "" {
|
||||
t.Fatalf("TUIHint(unknown) = %q, want empty", h)
|
||||
}
|
||||
if h := CLIHint(unknown); h != "" {
|
||||
t.Fatalf("CLIHint(unknown) = %q, want empty", h)
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,12 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry too — mirroring acquireFileLock in lock.go —
|
||||
// otherwise concurrent secret creation spuriously fails on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||
}
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
|
||||
@@ -169,8 +169,8 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
|
||||
if err := validateEndpoint(ctx, ep, r, true); err != nil {
|
||||
t.Errorf("local provider endpoint %q should be allowed with allowLoopback=true, got %v", ep, err)
|
||||
}
|
||||
if !endpointIsLoopback(ep) {
|
||||
t.Errorf("endpointIsLoopback(%q) = false, want true", ep)
|
||||
if !endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,14 +179,27 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
|
||||
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
|
||||
}
|
||||
|
||||
// allowLoopback relaxes loopback ONLY — other special ranges (cloud metadata,
|
||||
// link-local, private) stay blocked even for a local provider config.
|
||||
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.5:8080/v1"} {
|
||||
// allowLoopbackOrPrivate relaxes loopback AND private-network ranges — but
|
||||
// link-local and other special ranges (cloud metadata, documentation) stay
|
||||
// blocked even for a local provider config.
|
||||
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://192.0.2.1:8080/v1"} {
|
||||
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
|
||||
t.Errorf("non-loopback special address %q must stay blocked even with allowLoopback=true", ep)
|
||||
t.Errorf("special-use address %q must stay blocked even with flag=true", ep)
|
||||
}
|
||||
if endpointIsLoopback(ep) {
|
||||
t.Errorf("endpointIsLoopback(%q) = true, want false", ep)
|
||||
if endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = true, want false", ep)
|
||||
}
|
||||
}
|
||||
|
||||
// Private-network addresses (192.168.x.y / 10.x.y.z / 172.16.x.y) are
|
||||
// now allowed when the flag is true — the user is pointing at their own
|
||||
// LAN box (e.g. llama.cpp on another local machine).
|
||||
for _, ep := range []string{"http://192.168.1.100:8080/v1", "http://10.0.0.5:8080/v1", "http://172.16.0.10:8080/v1"} {
|
||||
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("192.168.1.100")}, true); err != nil {
|
||||
t.Errorf("private-network address %q should be allowed with flag=true, got %v", ep, err)
|
||||
}
|
||||
if !endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
request, allowLoopback, err := healthRequest(requestCtx, profile, kind, options)
|
||||
request, allowLoopbackOrPrivate, err := healthRequest(requestCtx, profile, kind, options)
|
||||
if err != nil {
|
||||
category := CategoryConfig
|
||||
var safety endpointSafetyError
|
||||
@@ -280,7 +280,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
}
|
||||
client := options.HTTPClient
|
||||
if client == nil {
|
||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopback)
|
||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
@@ -332,8 +332,8 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
||||
// Loopback is permitted only when the user's OWN configured base_url is loopback
|
||||
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
|
||||
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
|
||||
allowLoopback := endpointIsLoopback(endpoint)
|
||||
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopback); err != nil {
|
||||
allowLoopbackOrPrivate := endpointIsLoopbackOrPrivate(endpoint)
|
||||
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopbackOrPrivate); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
@@ -344,7 +344,7 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
||||
request.Header.Set("User-Agent", options.UserAgent)
|
||||
}
|
||||
applyAuth(request, profile, kind)
|
||||
return request, allowLoopback, nil
|
||||
return request, allowLoopbackOrPrivate, nil
|
||||
}
|
||||
|
||||
func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (string, error) {
|
||||
@@ -367,11 +367,11 @@ func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (
|
||||
}
|
||||
|
||||
// validateEndpoint enforces the SSRF policy for the provider connectivity probe.
|
||||
// allowLoopback permits loopback hosts (localhost / 127.0.0.0/8 / ::1) ONLY — it is
|
||||
// set true exclusively for the user's own explicitly-configured local provider
|
||||
// base_url (e.g. Ollama/LM Studio), never for redirect targets, and it relaxes
|
||||
// loopback alone (other private/special ranges, incl. cloud metadata, stay blocked).
|
||||
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopback bool) error {
|
||||
// allowLoopbackOrPrivate permits loopback hosts (localhost / 127.0.0.0/8 / ::1)
|
||||
// and private-network hosts (192.168.0.0/16 / 10.0.0.0/8 / 172.16.0.0/12) — it
|
||||
// is set true exclusively for the user's own explicitly-configured base_url
|
||||
// (e.g. Ollama/LM Studio on the LAN), never for redirect targets.
|
||||
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopbackOrPrivate bool) error {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -385,13 +385,13 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
}
|
||||
normalized := strings.TrimSuffix(strings.ToLower(host), ".")
|
||||
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
|
||||
if allowLoopback {
|
||||
if allowLoopbackOrPrivate {
|
||||
return nil // user-configured local provider; loopback is intentional
|
||||
}
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
|
||||
}
|
||||
if addr, err := netip.ParseAddr(normalized); err == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return nil
|
||||
@@ -407,17 +407,19 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// endpointIsLoopback reports whether the configured endpoint's host is a loopback
|
||||
// host the user typed themselves (localhost or a 127.0.0.0/8 / ::1 literal). Used to
|
||||
// decide whether the connectivity probe may reach the user's own local provider.
|
||||
func endpointIsLoopback(endpoint string) bool {
|
||||
// endpointIsLoopbackOrPrivate reports whether the configured endpoint's host is a loopback
|
||||
// or private-network host the user typed themselves — a local provider on the LAN
|
||||
// (e.g. 192.168.x.y / 10.x.y.z) or localhost. Used to decide whether the
|
||||
// connectivity probe may relax address-blocking and reach the user's own
|
||||
// explicitly-configured provider.
|
||||
func endpointIsLoopbackOrPrivate(endpoint string) bool {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -427,7 +429,7 @@ func endpointIsLoopback(endpoint string) bool {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
return addr.IsLoopback() || addr.IsPrivate()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -446,7 +448,7 @@ const maxConnectivityRedirects = 5
|
||||
// time and then dials the validated IP literal, closing the TOCTOU window
|
||||
// between the pre-flight validateEndpoint check and the actual connection
|
||||
// (DNS rebinding).
|
||||
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopback bool) *http.Client {
|
||||
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopbackOrPrivate bool) *http.Client {
|
||||
if resolver == nil {
|
||||
resolver = defaultResolver{}
|
||||
}
|
||||
@@ -456,7 +458,7 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
||||
} else {
|
||||
transport = &http.Transport{}
|
||||
}
|
||||
transport.DialContext = safeDialContext(resolver, allowLoopback)
|
||||
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
@@ -476,8 +478,8 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
||||
}
|
||||
}
|
||||
// Redirects are NEVER allowed to reach loopback, even for a local provider:
|
||||
// allowLoopback covers only the user's own configured base_url, not a 3xx
|
||||
// target (which could be attacker-controlled SSRF). (AUDIT-H1)
|
||||
// allowLoopbackOrPrivate covers only the user's own configured base_url,
|
||||
// not a 3xx target (which could be attacker-controlled SSRF). (AUDIT-H1)
|
||||
return validateEndpoint(req.Context(), req.URL.String(), resolver, false)
|
||||
},
|
||||
}
|
||||
@@ -519,7 +521,7 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
|
||||
// safeDialContext returns a dial function that resolves the target host, refuses
|
||||
// the connection if any resolved address is blocked, then dials the validated IP
|
||||
// literal so the kernel cannot re-resolve to a different address after the check.
|
||||
func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
|
||||
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
@@ -527,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
||||
return nil, err
|
||||
}
|
||||
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
@@ -540,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
||||
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ type Options struct {
|
||||
ModelRegistry *modelregistry.Registry
|
||||
// OAuthResolver, when set, lets the provider authenticate model calls with an
|
||||
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
|
||||
// Applied to the OpenAI and Anthropic providers.
|
||||
// Applied to the OpenAI, Anthropic, and Google (Gemini) providers.
|
||||
OAuthResolver providerio.TokenResolver
|
||||
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
|
||||
// there is no OAuth login). It is the SAME selection the bearer resolver made,
|
||||
@@ -91,6 +91,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
AuthScheme: profile.AuthScheme,
|
||||
AuthHeaderValue: profile.AuthHeaderValue,
|
||||
CustomHeaders: profile.CustomHeaders,
|
||||
OAuthResolver: options.OAuthResolver,
|
||||
MaxTokens: resolved.maxOutputTokens,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
|
||||
@@ -49,6 +49,10 @@ type Options struct {
|
||||
CustomHeaders map[string]string
|
||||
HTTPClient *http.Client
|
||||
UserAgent string
|
||||
// OAuthResolver, when set, supplies an OAuth bearer credential per request and
|
||||
// is retried once with a forced token refresh after an upstream 401 (matching
|
||||
// the OpenAI and Anthropic providers). Nil falls back to plain API-key auth.
|
||||
OAuthResolver providerio.TokenResolver
|
||||
// StreamIdleTimeout aborts the stream if no data arrives for this long.
|
||||
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
|
||||
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
|
||||
@@ -67,6 +71,7 @@ type Provider struct {
|
||||
customHeaders map[string]string
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
oauthResolver providerio.TokenResolver
|
||||
streamIdleTimeout time.Duration
|
||||
}
|
||||
|
||||
@@ -95,6 +100,7 @@ func New(options Options) (*Provider, error) {
|
||||
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
|
||||
httpClient: providerio.HTTPClient(options.HTTPClient),
|
||||
userAgent: options.UserAgent,
|
||||
oauthResolver: options.OAuthResolver,
|
||||
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
|
||||
}, nil
|
||||
}
|
||||
@@ -127,20 +133,22 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
defer cancelStream()
|
||||
|
||||
response, err := providerio.SendWithRetry(streamCtx, provider.httpClient, http.MethodPost, provider.streamURL(), body, func(request *http.Request) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{
|
||||
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, provider.streamURL(), body,
|
||||
providerio.AuthHeaders{
|
||||
APIKey: provider.apiKey,
|
||||
DefaultAuthHeader: "x-goog-api-key",
|
||||
AuthHeader: provider.authHeader,
|
||||
AuthScheme: provider.authScheme,
|
||||
AuthHeaderValue: provider.authHeaderValue,
|
||||
CustomHeaders: provider.customHeaders,
|
||||
})
|
||||
}, 0)
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
}, 0)
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
|
||||
@@ -370,6 +370,57 @@ func TestStreamCompletionClassifiesHTTPAndPromptBlockErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A 401 with an OAuth resolver is retried once with a force-refreshed token; the
|
||||
// replayed request carries the refreshed bearer and succeeds.
|
||||
func TestStreamCompletionRetries401WithRefreshedToken(t *testing.T) {
|
||||
var attempts int
|
||||
var secondAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
http.Error(w, `{"error":{"message":"token expired"}}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
secondAuth = r.Header.Get("Authorization")
|
||||
writeSSE(w, `{}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var forceRefreshOnRetry bool
|
||||
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
if forceRefresh {
|
||||
forceRefreshOnRetry = true
|
||||
return "Authorization", "Bearer refreshed", true, nil
|
||||
}
|
||||
return "Authorization", "Bearer stale", true, nil
|
||||
}
|
||||
|
||||
provider, err := New(Options{
|
||||
APIKey: "sk-google",
|
||||
BaseURL: server.URL,
|
||||
Model: "gemini-test",
|
||||
OAuthResolver: resolver,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), validRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2 (initial 401 + one refreshed retry)", attempts)
|
||||
}
|
||||
if !forceRefreshOnRetry {
|
||||
t.Fatalf("resolver was not called with forceRefresh on the retry")
|
||||
}
|
||||
if secondAuth != "Bearer refreshed" {
|
||||
t.Fatalf("retry Authorization = %q, want refreshed bearer", secondAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-google", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"error":{"code":429,"message":"stream failed sk-google","status":"RESOURCE_EXHAUSTED"}}`)
|
||||
|
||||
@@ -72,6 +72,9 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
|
||||
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
|
||||
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
|
||||
}
|
||||
// /tmp is a default temp write root on POSIX only (see
|
||||
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
|
||||
// against the current drive, so a stray C:\tmp must not turn this on.
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
|
||||
@@ -129,6 +129,9 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
|
||||
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
|
||||
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
|
||||
}
|
||||
// /tmp is a default temp write root on POSIX only (see
|
||||
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
|
||||
// against the current drive, so a stray C:\tmp must not turn this on.
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
|
||||
t.Fatalf("Roots()=%v want default /tmp write root", roots)
|
||||
}
|
||||
|
||||
@@ -61,8 +61,10 @@ func buildWindowsUnelevatedAppliedPlan(config WindowsSandboxCommandConfig) (Wind
|
||||
|
||||
// loadWindowsUnelevatedSetupMarker reads the marker; a missing file is the
|
||||
// common first-run state and returns an empty marker, not an error. A marker
|
||||
// with an unexpected schema version is treated as empty so the runner re-applies
|
||||
// (and rewrites) rather than failing.
|
||||
// with an unexpected schema version or a corrupt/truncated body is likewise
|
||||
// treated as empty so the runner re-applies (and rewrites) rather than failing:
|
||||
// the marker only memoizes idempotent ACL applies, so resetting it can only
|
||||
// cost redundant work, never skip enforcement.
|
||||
func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetupMarker, error) {
|
||||
sandboxHome = strings.TrimSpace(sandboxHome)
|
||||
if sandboxHome == "" {
|
||||
@@ -78,7 +80,7 @@ func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetu
|
||||
}
|
||||
var marker WindowsUnelevatedSetupMarker
|
||||
if err := json.Unmarshal(bytes, &marker); err != nil {
|
||||
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("parse windows unelevated setup marker %s: %w", path, err)
|
||||
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
|
||||
}
|
||||
if marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
|
||||
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
|
||||
|
||||
@@ -138,6 +138,38 @@ func TestWindowsUnelevatedSetupMarkerEvictsOldestPastCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsUnelevatedSetupMarkerSelfHealsCorruptFile(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), ".zero-sandbox")
|
||||
if err := os.MkdirAll(home, 0o700); err != nil {
|
||||
t.Fatalf("mkdir sandbox home: %v", err)
|
||||
}
|
||||
path := WindowsUnelevatedSetupMarkerPath(home)
|
||||
// A truncated body must reset like an unknown schema, not brick every
|
||||
// unelevated command until the file is hand-deleted.
|
||||
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"appliedPlans":[{"aclPlanHash":"h`), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt marker: %v", err)
|
||||
}
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(home)
|
||||
if err != nil {
|
||||
t.Fatalf("corrupt marker must self-heal, got error: %v", err)
|
||||
}
|
||||
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
|
||||
t.Fatalf("corrupt marker must reset to empty current-schema marker, got %+v", marker)
|
||||
}
|
||||
// The healed marker must accept new recordings over the corrupt file.
|
||||
applied := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-heal", ACLPlanEntries: 2}
|
||||
if err := recordWindowsUnelevatedAppliedPlan(home, applied); err != nil {
|
||||
t.Fatalf("record over corrupt marker: %v", err)
|
||||
}
|
||||
marker, err = loadWindowsUnelevatedSetupMarker(home)
|
||||
if err != nil {
|
||||
t.Fatalf("reload healed marker: %v", err)
|
||||
}
|
||||
if !marker.contains(applied) {
|
||||
t.Fatalf("healed marker = %+v, want the recorded plan", marker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsUnelevatedSetupMarkerResetsUnknownSchema(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), ".zero-sandbox")
|
||||
if err := os.MkdirAll(home, 0o700); err != nil {
|
||||
|
||||
@@ -160,10 +160,7 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
|
||||
if prepared.Mode == ModeNew || len(prepared.ContextEvents) == 0 {
|
||||
return prompt
|
||||
}
|
||||
events := prepared.ContextEvents
|
||||
if len(events) > 20 {
|
||||
events = events[len(events)-20:]
|
||||
}
|
||||
events := promptContextEvents(prepared.ContextEvents)
|
||||
|
||||
lines := []string{}
|
||||
for _, event := range events {
|
||||
@@ -187,6 +184,25 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func promptContextEvents(events []Event) []Event {
|
||||
const maxPromptContextEvents = 80
|
||||
|
||||
filtered := make([]Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case EventMessage, EventCompaction, EventSessionFork, EventSessionChild, EventSpecialistStart, EventSpecialistStop, EventError:
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
filtered = append(filtered, events...)
|
||||
}
|
||||
if len(filtered) > maxPromptContextEvents {
|
||||
filtered = filtered[len(filtered)-maxPromptContextEvents:]
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func forkTitle(title string) string {
|
||||
if title == "" {
|
||||
return ""
|
||||
|
||||
@@ -71,7 +71,7 @@ type CompactionPayload struct {
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
const defaultCompactionPreserveLast = 8
|
||||
const defaultCompactionPreserveLast = 6
|
||||
const defaultCompactionMaxPromptChars = 8000
|
||||
|
||||
func (store *Store) PlanRewind(sessionID string, options RewindOptions) (RewindPlan, error) {
|
||||
|
||||
@@ -555,6 +555,91 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatExecPromptKeepsConversationMessagesWhenNoisyEventsFollow(t *testing.T) {
|
||||
events := []Event{
|
||||
{Sequence: 1, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"first user request"}`)},
|
||||
{Sequence: 2, Type: EventMessage, Payload: json.RawMessage(`{"role":"assistant","content":"first assistant answer"}`)},
|
||||
}
|
||||
for sequence := 3; sequence <= 45; sequence++ {
|
||||
events = append(events, Event{Sequence: sequence, Type: EventToolResult, Payload: json.RawMessage(`{"name":"read_file","output":"noisy tool result"}`)})
|
||||
}
|
||||
events = append(events, Event{Sequence: 46, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"latest user request"}`)})
|
||||
|
||||
prompt := FormatExecPrompt("continue", PreparedExec{
|
||||
Mode: ModeResume,
|
||||
Session: Metadata{SessionID: "session-with-noise"},
|
||||
ContextEvents: events,
|
||||
})
|
||||
|
||||
for _, want := range []string{"first user request", "first assistant answer", "latest user request", "Current user request:", "continue"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
if strings.Contains(prompt, "noisy tool result") {
|
||||
t.Fatalf("expected prompt to omit noisy non-conversation events, got %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *testing.T) {
|
||||
events := make([]Event, 0, 100)
|
||||
for sequence := 1; sequence <= 85; sequence++ {
|
||||
events = append(events, Event{
|
||||
Sequence: sequence,
|
||||
Type: EventMessage,
|
||||
Payload: json.RawMessage(fmt.Sprintf(`{"role":"user","content":"conversation message %03d"}`, sequence)),
|
||||
})
|
||||
if sequence%10 == 0 {
|
||||
events = append(events, Event{
|
||||
Sequence: 1000 + sequence,
|
||||
Type: EventToolResult,
|
||||
Payload: json.RawMessage(fmt.Sprintf(`{"name":"read_file","output":"noisy tool result %03d"}`, sequence)),
|
||||
})
|
||||
}
|
||||
}
|
||||
events = append(events, Event{
|
||||
Sequence: 2000,
|
||||
Type: EventToolResult,
|
||||
Payload: json.RawMessage(`{"name":"bash","output":"trailing noisy tool result"}`),
|
||||
})
|
||||
|
||||
contextEvents := promptContextEvents(events)
|
||||
if len(contextEvents) != 80 {
|
||||
t.Fatalf("expected prompt context to keep 80 events, got %d", len(contextEvents))
|
||||
}
|
||||
if contextEvents[0].Sequence != 6 || contextEvents[len(contextEvents)-1].Sequence != 85 {
|
||||
t.Fatalf("expected prompt context to keep message sequences 6-85, got first=%d last=%d", contextEvents[0].Sequence, contextEvents[len(contextEvents)-1].Sequence)
|
||||
}
|
||||
|
||||
prompt := FormatExecPrompt("continue", PreparedExec{
|
||||
Mode: ModeResume,
|
||||
Session: Metadata{SessionID: "long-session-with-noise"},
|
||||
ContextEvents: events,
|
||||
})
|
||||
|
||||
if got, want := strings.Count(prompt, "- #"), len(contextEvents); got != want {
|
||||
t.Fatalf("expected prompt to contain %d context event lines, got %d in %q", want, got, prompt)
|
||||
}
|
||||
for _, want := range []string{"conversation message 006", "conversation message 085", "Current user request:", "continue"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"conversation message 001",
|
||||
"conversation message 002",
|
||||
"conversation message 003",
|
||||
"conversation message 004",
|
||||
"conversation message 005",
|
||||
"noisy tool result",
|
||||
"trailing noisy tool result",
|
||||
} {
|
||||
if strings.Contains(prompt, unwanted) {
|
||||
t.Fatalf("expected prompt to omit %q, got %q", unwanted, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
|
||||
|
||||
|
||||
@@ -272,15 +272,36 @@ func coerceAskUserOptions(optionsValue, descsValue any) (labels []string, descri
|
||||
// FormatAskUserAnswers renders question/answer pairs into a clear, model-readable
|
||||
// block. Missing answers are surfaced explicitly so the model never silently
|
||||
// treats an unanswered question as answered.
|
||||
//
|
||||
// It distinguishes two shapes of "empty" the model would otherwise conflate: a
|
||||
// wholesale dismissal (the user closed the prompt without answering ANYTHING) is
|
||||
// flagged up front as a skip, so the model doesn't invent a default; a single
|
||||
// blank field amid other answers is marked "(left blank)".
|
||||
func FormatAskUserAnswers(questions []AskUserQuestion, answers []string) string {
|
||||
lines := make([]string, 0, len(questions)*3)
|
||||
anyAnswered := false
|
||||
for index := range questions {
|
||||
if index < len(answers) && strings.TrimSpace(answers[index]) != "" {
|
||||
anyAnswered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(questions)*3+2)
|
||||
if len(questions) > 0 && !anyAnswered {
|
||||
lines = append(lines, "[note] The user dismissed this prompt without answering. Treat this as a skip: do not assume a default — ask again more specifically, or proceed only if the intent is already unambiguous.")
|
||||
lines = append(lines, "")
|
||||
}
|
||||
for index, question := range questions {
|
||||
answer := ""
|
||||
if index < len(answers) {
|
||||
answer = strings.TrimSpace(answers[index])
|
||||
}
|
||||
if answer == "" {
|
||||
answer = "(no answer provided)"
|
||||
if anyAnswered {
|
||||
answer = "(left blank)"
|
||||
} else {
|
||||
answer = "(skipped)"
|
||||
}
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. [question] %s", index+1, question.Question))
|
||||
lines = append(lines, "[answer] "+answer)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatAskUserAnswersDistinguishesSkipFromBlank(t *testing.T) {
|
||||
questions := []AskUserQuestion{
|
||||
{Question: "Which database?"},
|
||||
{Question: "Migrate existing data?"},
|
||||
}
|
||||
|
||||
// Wholesale dismissal (nothing answered): flagged as a skip up front.
|
||||
dismissed := FormatAskUserAnswers(questions, []string{"", ""})
|
||||
if !strings.Contains(dismissed, "dismissed this prompt") || !strings.Contains(dismissed, "Treat this as a skip") {
|
||||
t.Fatalf("all-empty answers must be flagged as a skip, got:\n%s", dismissed)
|
||||
}
|
||||
if strings.Contains(dismissed, "left blank") {
|
||||
t.Fatalf("a full dismissal should read as skipped, not left-blank:\n%s", dismissed)
|
||||
}
|
||||
if !strings.Contains(dismissed, "(skipped)") {
|
||||
t.Fatalf("dismissed questions should render (skipped):\n%s", dismissed)
|
||||
}
|
||||
|
||||
// Partial answer: the blank one is "left blank", no wholesale-skip note.
|
||||
partial := FormatAskUserAnswers(questions, []string{"postgres", ""})
|
||||
if strings.Contains(partial, "dismissed this prompt") {
|
||||
t.Fatalf("a partial answer is not a dismissal:\n%s", partial)
|
||||
}
|
||||
if !strings.Contains(partial, "postgres") || !strings.Contains(partial, "(left blank)") {
|
||||
t.Fatalf("partial answers should show the answer and mark the blank one left-blank:\n%s", partial)
|
||||
}
|
||||
}
|
||||
+148
-17
@@ -1,7 +1,6 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -124,10 +123,14 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
defer plan.Cleanup()
|
||||
addSandboxMeta(meta, plan)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
// Bound the capture so a command with runaway output (`cat huge.log`, `yes`)
|
||||
// can't grow Zero's memory before truncation: only the head+tail each stream
|
||||
// will ever surface to the model are retained, the middle is discarded as it
|
||||
// streams, and the true size is counted for the truncation marker.
|
||||
stdout := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
|
||||
stderr := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
|
||||
command.Stdout = stdout
|
||||
command.Stderr = stderr
|
||||
|
||||
// Kill the shell as a process group on timeout and bound the post-kill I/O
|
||||
// wait, so a backgrounded child cannot outlive the command or hang Run().
|
||||
@@ -139,7 +142,12 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
err = command.Run()
|
||||
exitCode := commandExitCode(err)
|
||||
meta["exit_code"] = strconv.Itoa(exitCode)
|
||||
stderrText := appendSandboxBlocks(stderr.String(), monitor.Stop())
|
||||
stdoutText := stdout.retained()
|
||||
stderrRetained := stderr.retained()
|
||||
stderrText := appendSandboxBlocks(stderrRetained, monitor.Stop())
|
||||
// Sandbox blocks are extra model-visible stderr bytes appended after capture;
|
||||
// count them toward the true total so the budget/marker stay accurate.
|
||||
stderrTotal := stderr.total + (len(stderrText) - len(stderrRetained))
|
||||
|
||||
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
|
||||
return Result{
|
||||
@@ -156,26 +164,31 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdout.String(), stderrText)
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
|
||||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, stdout.String(), stderrText, exitCode, meta),
|
||||
Meta: meta,
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdout.String(), stderrText)
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
|
||||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||||
if meta[SandboxLikelyDeniedMeta] == "true" {
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, stdout.String(), stderrText, exitCode, meta),
|
||||
Meta: meta,
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: formatBashOutput(stdout.String(), stderrText, exitCode),
|
||||
Meta: meta,
|
||||
Status: StatusOK,
|
||||
Output: formatBashOutput(outText, errText, exitCode),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +358,124 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model. bash is the
|
||||
// one tool that can emit unbounded output (`cat large.log`, `find /`, verbose test
|
||||
// runs); every other read/search tool already budgets its output. Head+tail
|
||||
// truncation keeps both the start and the end of an oversized stream, since
|
||||
// build/test failures usually surface at the tail.
|
||||
const bashOutputBudgetBytes = 96 * 1024
|
||||
|
||||
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
|
||||
// keeping the head and tail of anything larger, and records raw/emitted byte
|
||||
// counts plus a truncated flag in meta (mirroring outputBudgetMeta's shape for
|
||||
// the read/search tools). Detection that needs the full output (sandbox-denial
|
||||
// scanning) must run on the raw strings before this is applied.
|
||||
func budgetBashOutput(stdout string, stderr string, meta map[string]string) (string, string, bool) {
|
||||
return budgetBashCapture(stdout, len(stdout), stderr, len(stderr), meta)
|
||||
}
|
||||
|
||||
// budgetBashCapture is budgetBashOutput for the streaming-capture path: outTotal
|
||||
// and errTotal are the true byte counts (from boundedBuffer.total), which may
|
||||
// exceed the retained strings when the middle was dropped during capture. Meta's
|
||||
// raw_bytes therefore reflects everything the command produced, not just what was
|
||||
// kept in memory.
|
||||
func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, meta map[string]string) (string, string, bool) {
|
||||
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
|
||||
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
|
||||
truncated := outTrunc || errTrunc
|
||||
if meta != nil {
|
||||
emitted := len(outText) + len(errText)
|
||||
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
|
||||
meta["emitted_bytes"] = strconv.Itoa(emitted)
|
||||
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(emitted))
|
||||
if truncated {
|
||||
meta["truncated"] = "true"
|
||||
}
|
||||
}
|
||||
return outText, errText, truncated
|
||||
}
|
||||
|
||||
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
|
||||
// and tailCap bytes from the end of a stream while counting the total written, so
|
||||
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
|
||||
// memory: the middle is discarded as it arrives instead of buffered whole and then
|
||||
// truncated. total records the full size for the truncation marker even though the
|
||||
// middle is never held. Not safe for concurrent writes; exec drives Stdout and
|
||||
// Stderr from separate goroutines, so each stream gets its own buffer.
|
||||
type boundedBuffer struct {
|
||||
head []byte
|
||||
headCap int
|
||||
tail []byte
|
||||
tailCap int
|
||||
total int
|
||||
}
|
||||
|
||||
func newBoundedBuffer(headCap, tailCap int) *boundedBuffer {
|
||||
return &boundedBuffer{headCap: headCap, tailCap: tailCap}
|
||||
}
|
||||
|
||||
func (b *boundedBuffer) Write(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
b.total += n
|
||||
// Fill the head until it reaches headCap; the head is written once and frozen.
|
||||
if len(b.head) < b.headCap {
|
||||
take := b.headCap - len(b.head)
|
||||
if take > len(p) {
|
||||
take = len(p)
|
||||
}
|
||||
b.head = append(b.head, p[:take]...)
|
||||
p = p[take:]
|
||||
}
|
||||
// Anything past the head feeds a rolling tail that keeps only the last tailCap
|
||||
// bytes. Compact once it grows past 2×tailCap so the append stays amortized O(1)
|
||||
// while memory never exceeds ~2×tailCap.
|
||||
if len(p) > 0 && b.tailCap > 0 {
|
||||
b.tail = append(b.tail, p...)
|
||||
if len(b.tail) > 2*b.tailCap {
|
||||
b.tail = append(b.tail[:0], b.tail[len(b.tail)-b.tailCap:]...)
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// retained returns the kept head+tail bytes (marker-less) as a string. The junction
|
||||
// between head and tail lands in the middle, which the display budget trims away;
|
||||
// callers that need the true size read total separately.
|
||||
func (b *boundedBuffer) retained() string {
|
||||
if len(b.tail) > b.tailCap {
|
||||
// Not yet compacted since the last overflow; expose only the last tailCap.
|
||||
return string(b.head) + string(b.tail[len(b.tail)-b.tailCap:])
|
||||
}
|
||||
return string(b.head) + string(b.tail)
|
||||
}
|
||||
|
||||
// truncateHeadTail keeps the first and last halves of value when it exceeds
|
||||
// maxBytes, dropping the middle behind a marker. Returns the possibly-truncated
|
||||
// text, the raw byte length, and whether truncation happened.
|
||||
func truncateHeadTail(value string, maxBytes int) (string, int, bool) {
|
||||
// value holds the whole stream, so its length is the true raw size.
|
||||
return truncateHeadTailWithTotal(value, len(value), maxBytes)
|
||||
}
|
||||
|
||||
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
|
||||
// the full original byte count — for the "N bytes omitted" marker and the raw
|
||||
// count. total may exceed len(value) when the middle was already discarded during
|
||||
// bounded capture (boundedBuffer): value then holds only the retained head+tail,
|
||||
// and this trims it to the display budget while still reporting the true total.
|
||||
func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int, bool) {
|
||||
if maxBytes <= 0 || total <= maxBytes {
|
||||
return value, total, false
|
||||
}
|
||||
marker := fmt.Sprintf("\n[zero] output truncated: %d bytes omitted from the middle — redirect to a file and read_file a range for the full text\n", total-maxBytes)
|
||||
budget := maxBytes - len(marker)
|
||||
if budget < 0 {
|
||||
budget = 0
|
||||
}
|
||||
head := budget / 2
|
||||
tail := budget - head
|
||||
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
|
||||
}
|
||||
|
||||
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||
output := formatBashOutput(stdout, stderr, exitCode)
|
||||
if issue := detectShellOutputIssue(command, stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Small output passes through untouched and records raw==emitted, no truncated flag.
|
||||
func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
|
||||
meta := map[string]string{}
|
||||
out, errStr, truncated := budgetBashOutput("hello\n", "warn\n", meta)
|
||||
if out != "hello\n" || errStr != "warn\n" {
|
||||
t.Fatalf("small output altered: out=%q err=%q", out, errStr)
|
||||
}
|
||||
if truncated {
|
||||
t.Fatalf("small output must report truncated=false")
|
||||
}
|
||||
if meta["truncated"] == "true" {
|
||||
t.Fatalf("small output must not be flagged truncated: %v", meta)
|
||||
}
|
||||
if meta["raw_bytes"] != strconv.Itoa(len("hello\n")+len("warn\n")) {
|
||||
t.Fatalf("raw_bytes wrong: %v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
// Oversized stdout is truncated head+tail: both the first and last lines survive,
|
||||
// the middle is dropped behind a marker, and meta is flagged.
|
||||
func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
|
||||
head := "FIRST_LINE_MARKER\n"
|
||||
tail := "\nLAST_LINE_MARKER"
|
||||
big := head + strings.Repeat("x", bashOutputBudgetBytes) + tail
|
||||
|
||||
meta := map[string]string{}
|
||||
out, _, truncated := budgetBashOutput(big, "", meta)
|
||||
|
||||
if !truncated {
|
||||
t.Fatalf("oversized output must report truncated=true")
|
||||
}
|
||||
if !strings.Contains(out, "FIRST_LINE_MARKER") {
|
||||
t.Fatalf("head lost after truncation")
|
||||
}
|
||||
if !strings.Contains(out, "LAST_LINE_MARKER") {
|
||||
t.Fatalf("tail lost after truncation (failures live at the tail)")
|
||||
}
|
||||
if !strings.Contains(out, "output truncated") {
|
||||
t.Fatalf("expected a truncation marker, got:\n%s", out[:200])
|
||||
}
|
||||
if len(out) > bashOutputBudgetBytes {
|
||||
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
|
||||
}
|
||||
if meta["truncated"] != "true" {
|
||||
t.Fatalf("expected truncated=true, got %v", meta)
|
||||
}
|
||||
if meta["raw_bytes"] != strconv.Itoa(len(big)) {
|
||||
t.Fatalf("raw_bytes = %s, want %d", meta["raw_bytes"], len(big))
|
||||
}
|
||||
if got, _ := strconv.Atoi(meta["emitted_bytes"]); got != len(out) {
|
||||
t.Fatalf("emitted_bytes = %s, want %d", meta["emitted_bytes"], len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// boundedBuffer must keep memory bounded to head+tail while counting every byte,
|
||||
// so a runaway command can't OOM Zero before truncation runs. It keeps the very
|
||||
// first bytes (head) and the very last bytes (tail), discarding the middle.
|
||||
func TestBoundedBufferKeepsHeadAndTailBounded(t *testing.T) {
|
||||
b := newBoundedBuffer(8, 8)
|
||||
total := 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
chunk := []byte(fmt.Sprintf("%05d.", i)) // 6 bytes each
|
||||
n, err := b.Write(chunk)
|
||||
if err != nil || n != len(chunk) {
|
||||
t.Fatalf("Write = (%d, %v), want (%d, nil)", n, err, len(chunk))
|
||||
}
|
||||
total += len(chunk)
|
||||
}
|
||||
if b.total != total {
|
||||
t.Fatalf("total = %d, want %d (every byte must be counted)", b.total, total)
|
||||
}
|
||||
// Retained memory is bounded regardless of how much streamed through.
|
||||
if len(b.head) > 8 {
|
||||
t.Fatalf("head grew past its cap: %d", len(b.head))
|
||||
}
|
||||
if len(b.tail) > 2*8 {
|
||||
t.Fatalf("tail grew past 2×cap (compaction failed): %d", len(b.tail))
|
||||
}
|
||||
r := b.retained()
|
||||
if len(r) > 8+8 {
|
||||
t.Fatalf("retained %d bytes, want <= headCap+tailCap", len(r))
|
||||
}
|
||||
if !strings.HasPrefix(r, "00000.") {
|
||||
t.Fatalf("retained head lost, got prefix %q", r[:min(6, len(r))])
|
||||
}
|
||||
if !strings.HasSuffix(r, "00999.") {
|
||||
t.Fatalf("retained tail lost, got %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
// budgetBashCapture reports the TRUE total (not the retained size) in the marker
|
||||
// and raw_bytes, even though only a bounded head+tail was ever held in memory.
|
||||
func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
|
||||
// Retained head+tail as boundedBuffer would hand over; the real command produced
|
||||
// far more than was kept.
|
||||
retained := "HEAD_START" + strings.Repeat("y", bashOutputBudgetBytes) + "TAIL_END"
|
||||
total := 10 * bashOutputBudgetBytes
|
||||
|
||||
meta := map[string]string{}
|
||||
out, _, truncated := budgetBashCapture(retained, total, "", 0, meta)
|
||||
|
||||
if !truncated {
|
||||
t.Fatal("oversized capture must report truncated=true")
|
||||
}
|
||||
if !strings.Contains(out, "HEAD_START") || !strings.Contains(out, "TAIL_END") {
|
||||
t.Fatalf("head/tail lost after budgeting:\n%s", out[:min(120, len(out))])
|
||||
}
|
||||
if len(out) > bashOutputBudgetBytes {
|
||||
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
|
||||
}
|
||||
if meta["raw_bytes"] != strconv.Itoa(total) {
|
||||
t.Fatalf("raw_bytes = %s, want the true total %d", meta["raw_bytes"], total)
|
||||
}
|
||||
// The marker must cite the true omitted count (total-budget), not retained-budget.
|
||||
if !strings.Contains(out, strconv.Itoa(total-bashOutputBudgetBytes)) {
|
||||
t.Fatalf("marker should cite the true omitted byte count:\n%s", out[:min(200, len(out))])
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -161,6 +162,35 @@ func TestDetectShellCommandIssueRequiresActualLSCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git log --oneline -- pinokio_compat.py | head`,
|
||||
`git blame -L 40,46 pinokio_compat.py --format=%author | grep summary`,
|
||||
`cat file.txt | wc -l`,
|
||||
`some_command | tail -n 20`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
|
||||
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git log --oneline`,
|
||||
`dir /b`,
|
||||
`findstr /r "summary" file.txt`,
|
||||
`echo header text`,
|
||||
`tail-log.sh`,
|
||||
`grep-cli --version`,
|
||||
`sed.exe --help`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
||||
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
|
||||
if issue == nil {
|
||||
@@ -211,6 +241,43 @@ func TestBashToolRunsCommandInWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A command with runaway output must not be buffered whole in memory: the capture
|
||||
// is bounded to head+tail, yet raw_bytes still reports the true (much larger) size.
|
||||
// If capture were unbounded this would balloon Zero's memory before truncation.
|
||||
func TestBashToolBoundsRunawayOutputCapture(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell pipeline (yes | head)")
|
||||
}
|
||||
const produced = 500000 // ~5× the 96 KiB budget
|
||||
|
||||
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
"command": fmt.Sprintf("yes ABCDEFGH | head -c %d", produced),
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !result.Truncated || result.Meta["truncated"] != "true" {
|
||||
t.Fatalf("runaway output should be flagged truncated, meta=%v", result.Meta)
|
||||
}
|
||||
if !strings.Contains(result.Output, "output truncated") {
|
||||
t.Fatalf("expected a truncation marker in output")
|
||||
}
|
||||
// The emitted (model-visible) text is capped near the budget...
|
||||
if len(result.Output) > bashOutputBudgetBytes+1024 {
|
||||
t.Fatalf("emitted %d bytes far exceeds budget %d", len(result.Output), bashOutputBudgetBytes)
|
||||
}
|
||||
// ...but raw_bytes reflects the full stream, proving the total was counted while
|
||||
// only a bounded slice was ever held (raw >> the ~2×budget retention cap).
|
||||
raw, err := strconv.Atoi(result.Meta["raw_bytes"])
|
||||
if err != nil {
|
||||
t.Fatalf("raw_bytes not an int: %q", result.Meta["raw_bytes"])
|
||||
}
|
||||
if raw < produced {
|
||||
t.Fatalf("raw_bytes = %d, want >= %d (full stream counted)", raw, produced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolUsesRequestedCwd(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nested := filepath.Join(root, "nested")
|
||||
|
||||
@@ -20,6 +20,11 @@ type shellIssue struct {
|
||||
var (
|
||||
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
|
||||
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
|
||||
// windowsPosixUtilityPattern catches the other common POSIX coreutils/grep
|
||||
// family commands cmd.exe has no equivalent for (unlike `dir`/`findstr`,
|
||||
// which sound similar enough that a model might reach for the POSIX name
|
||||
// instead) — most often seen piped in, e.g. `git log ... | head`.
|
||||
windowsPosixUtilityPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)(head|tail|grep|wc|awk|sed|cut|xargs|tr)(?:\s+|$)`)
|
||||
)
|
||||
|
||||
func detectShellRuntime(goos string) shellRuntime {
|
||||
@@ -57,6 +62,13 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
|
||||
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
|
||||
}
|
||||
}
|
||||
if windowsPosixUtilityPattern.MatchString(trimmed) {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Command uses a POSIX utility (head/tail/grep/wc/awk/sed/cut/xargs/tr) that Windows cmd.exe does not have.",
|
||||
Suggestion: "Use cmd.exe equivalents (e.g. `findstr` for grep, `more` to page output) or Zero's native tools (grep, read_file with offset/limit) instead of piping to a POSIX utility.",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -261,10 +261,16 @@ func (tool toolSearchTool) rankByKeyword(query string, deferred []Tool) []Tool {
|
||||
for index, candidate := range deferred {
|
||||
name := strings.ToLower(candidate.Name())
|
||||
desc := strings.ToLower(candidate.Description())
|
||||
nameSquashed := squashSeparators(name)
|
||||
score := 0
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(name, keyword) {
|
||||
switch {
|
||||
case strings.Contains(name, keyword):
|
||||
score += 2
|
||||
case strings.Contains(nameSquashed, squashSeparators(keyword)):
|
||||
// Separator-insensitive fallback: "webfetch" matches "web_fetch".
|
||||
// Ranked below an exact substring so precise queries still win.
|
||||
score++
|
||||
}
|
||||
if strings.Contains(desc, keyword) {
|
||||
score++
|
||||
@@ -290,6 +296,19 @@ func (tool toolSearchTool) rankByKeyword(query string, deferred []Tool) []Tool {
|
||||
return matches
|
||||
}
|
||||
|
||||
// squashSeparators drops separators so "web_fetch", "web-fetch", and "web fetch"
|
||||
// all normalize to "webfetch". It lets a query missing the separators still match
|
||||
// a tool name, the common way a model mistypes a tool ("webfetch").
|
||||
func squashSeparators(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '_', '-', ' ', '.':
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// noMatchMessage reports that nothing loaded and names the available deferred
|
||||
// tools so the model can retry with a valid select: query.
|
||||
func (tool toolSearchTool) noMatchMessage(query string, deferred []Tool) string {
|
||||
|
||||
@@ -184,6 +184,29 @@ func TestToolSearchKeywordRanksByNameThenDescription(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A query missing the name's separators still matches: "webfetch" -> web_fetch.
|
||||
func TestToolSearchKeywordMatchesSeparatorInsensitive(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.Register(searchFakeTool{
|
||||
name: "web_fetch",
|
||||
description: "Fetch a URL.",
|
||||
parameters: Schema{Type: "object", AdditionalProperties: false},
|
||||
})
|
||||
reg.Register(searchFakeTool{
|
||||
name: "stock_quote",
|
||||
description: "Get a stock price.",
|
||||
parameters: Schema{Type: "object", AdditionalProperties: false},
|
||||
})
|
||||
tool := NewToolSearchTool(reg).(optionsAwareTool)
|
||||
|
||||
result := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "webfetch"}, RunOptions{})
|
||||
|
||||
if got := result.Meta["load_tools"]; got != "web_fetch" {
|
||||
t.Fatalf("load_tools = %q, want web_fetch (separator-insensitive match)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSearchKeywordExcludesNonMatches(t *testing.T) {
|
||||
reg := newDeferredFixtureRegistry()
|
||||
tool := NewToolSearchTool(reg).(optionsAwareTool)
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestParseBindingLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string // expected Label()
|
||||
}{
|
||||
{"option+o", "Alt+O"},
|
||||
{"ctrl+o", "Ctrl+O"},
|
||||
{"ctrl+e", "Ctrl+E"},
|
||||
{"option+b", "Alt+B"},
|
||||
{"option+O", "Alt+O"},
|
||||
{"alt+o", "Alt+O"},
|
||||
{"cmd+o", "Cmd+O"},
|
||||
{"super+o", "Cmd+O"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
p := parseBinding(tt.input)
|
||||
got := p.Label()
|
||||
if got != tt.want {
|
||||
t.Errorf("parseBinding(%q).Label() = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
if p.isZero() {
|
||||
t.Errorf("parseBinding(%q).isZero() = true, want false", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionOMatchesOptionO(t *testing.T) {
|
||||
p := parseBinding("option+o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
// Simulate iTerm2 with "Option as Meta" pressing Option+O
|
||||
// Terminal sends ESC o → KeyPressEvent{Code: 'o', Mod: ModAlt}
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
|
||||
|
||||
if !matcher(msg) {
|
||||
t.Errorf("option+o matcher should match {Code:'o', Mod:ModAlt}")
|
||||
}
|
||||
|
||||
// Should NOT match plain 'o'
|
||||
msg2 := tea.KeyPressMsg(tea.Key{Code: 'o'})
|
||||
if matcher(msg2) {
|
||||
t.Errorf("option+o matcher should NOT match {Code:'o'} without ModAlt")
|
||||
}
|
||||
|
||||
// Should NOT match ctrl+o
|
||||
msg3 := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
||||
if matcher(msg3) {
|
||||
t.Errorf("option+o matcher should NOT match {Code:'o', Mod:ModCtrl}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionOMatchesComposedCharacters(t *testing.T) {
|
||||
p := parseBinding("option+o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
// On macOS without "Option as Meta", Option+O produces ø (U+00F8)
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 0xF8, Text: "ø"})
|
||||
if matcher(msg) {
|
||||
t.Logf("NOTE: option+o matcher also matches option+ø (macOS compose behavior)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlEMatchesDefault(t *testing.T) {
|
||||
p := parseBinding("ctrl+e")
|
||||
matcher := p.Matcher()
|
||||
|
||||
// Terminal sends Ctrl+E → byte 0x05 → KeyPressEvent{Code: 'e', Mod: ModCtrl}
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl})
|
||||
|
||||
if !matcher(msg) {
|
||||
t.Errorf("ctrl+e matcher should match {Code:'e', Mod:ModCtrl}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlBindingRejectsSupersetModifiers(t *testing.T) {
|
||||
// Configured ctrl+e must NOT match ctrl+alt+e or ctrl+shift+e
|
||||
// (Mod.Contains superset correctness for the ctrl-letter fast path).
|
||||
p := parseBinding("ctrl+e")
|
||||
matcher := p.Matcher()
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
}{
|
||||
{"ctrl+alt+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt})},
|
||||
{"ctrl+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModShift})},
|
||||
{"ctrl+alt+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt | tea.ModShift})},
|
||||
{"ctrl+cmd+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModSuper})},
|
||||
}
|
||||
|
||||
for _, tt := range bad {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if matcher(tt.msg) {
|
||||
t.Errorf("ctrl+e matcher should NOT match %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltBindingRejectsSupersetModifiers(t *testing.T) {
|
||||
// Configured alt+o must NOT match alt+shift+o (code path, exact mod).
|
||||
p := parseBinding("alt+o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
}{
|
||||
{"alt+shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModShift})},
|
||||
{"alt+ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModCtrl})},
|
||||
{"alt+cmd+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModSuper})},
|
||||
}
|
||||
|
||||
for _, tt := range bad {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if matcher(tt.msg) {
|
||||
t.Errorf("alt+o matcher should NOT match %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainKeyBindingRejectsModifiers(t *testing.T) {
|
||||
// A configured plain-key binding (no modifiers, e.g. "o") should only
|
||||
// match when Mod is exactly 0, not when a modifier is held.
|
||||
p := parseBinding("o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
plain := tea.KeyPressMsg(tea.Key{Code: 'o'})
|
||||
if !matcher(plain) {
|
||||
t.Errorf("'o' matcher should match {Code:'o'} with no modifiers")
|
||||
}
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
}{
|
||||
{"ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})},
|
||||
{"alt+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})},
|
||||
{"shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModShift})},
|
||||
}
|
||||
|
||||
for _, tt := range bad {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if matcher(tt.msg) {
|
||||
t.Errorf("'o' matcher should NOT match %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBindingUTF8Character(t *testing.T) {
|
||||
// A 2-byte UTF-8 key like é (U+00E9) should parse as a single character,
|
||||
// not be rejected by a byte-length check.
|
||||
p := parseBinding("é")
|
||||
if p.isZero() {
|
||||
t.Errorf("parseBinding('é') should not be zero — it's a valid single character")
|
||||
}
|
||||
if p.code != 'é' {
|
||||
t.Errorf("parseBinding('é').code = %d (U+%04X), want %d (U+00E9)", p.code, p.code, 'é')
|
||||
}
|
||||
if p.text != "" {
|
||||
t.Errorf("parseBinding('é').text = %q, want empty string", p.text)
|
||||
}
|
||||
|
||||
// Also verify the matcher works
|
||||
matcher := p.Matcher()
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é"})
|
||||
if !matcher(msg) {
|
||||
t.Errorf("'é' matcher should match {Code:'é', Text:'é'}")
|
||||
}
|
||||
|
||||
// Should not match with a modifier
|
||||
msgAlt := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é", Mod: tea.ModAlt})
|
||||
if matcher(msgAlt) {
|
||||
t.Errorf("'é' matcher should NOT match with modifier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRejectsSupersetModifiers(t *testing.T) {
|
||||
// Integration: model.keyMatch with a configured binding must reject
|
||||
// modifier supersets.
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "ctrl+o",
|
||||
}
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
defaultFn := func(tea.KeyMsg) bool { return false }
|
||||
m := model{keyBindings: bindings}
|
||||
|
||||
// Ctrl+O should match
|
||||
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
||||
if !m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
|
||||
t.Errorf("keyMatch should match ctrl+o when configured")
|
||||
}
|
||||
|
||||
// Ctrl+Alt+O should NOT match
|
||||
msgCtrlAltO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl | tea.ModAlt})
|
||||
if m.keyMatch(bindings.toggleDetailed, msgCtrlAltO, defaultFn) {
|
||||
t.Errorf("keyMatch should NOT match ctrl+alt+o when ctrl+o is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigToBindingPipeline(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
ToggleMouse: "ctrl+e",
|
||||
CycleReasoning: "ctrl+t",
|
||||
TogglePlan: "ctrl+p",
|
||||
ToggleSidebar: "option+b",
|
||||
}
|
||||
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
binding parsedBinding
|
||||
wantKey string
|
||||
}{
|
||||
{"toggleDetailed", bindings.toggleDetailed, "Alt+O"},
|
||||
{"toggleMouse", bindings.toggleMouse, "Ctrl+E"},
|
||||
{"cycleReasoning", bindings.cycleReasoning, "Ctrl+T"},
|
||||
{"togglePlan", bindings.togglePlan, "Ctrl+P"},
|
||||
{"toggleSidebar", bindings.toggleSidebar, "Alt+B"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.binding.isZero() {
|
||||
t.Errorf("%s binding is zero (unset) — config not loaded", tt.name)
|
||||
}
|
||||
if got := tt.binding.Label(); got != tt.wantKey {
|
||||
t.Errorf("%s.Label() = %q, want %q", tt.name, got, tt.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpBindingSubstitution(t *testing.T) {
|
||||
// Verify that the help overlay correctly shows user-configured bindings
|
||||
// by checking that Label() returns non-empty for config-sourced bindings.
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
ToggleSidebar: "option+b",
|
||||
}
|
||||
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
|
||||
if l := bindings.toggleDetailed.Label(); l != "Alt+O" {
|
||||
t.Errorf("toggleDetailed.Label() = %q, want %q", l, "Alt+O")
|
||||
}
|
||||
if l := bindings.toggleSidebar.Label(); l != "Alt+B" {
|
||||
t.Errorf("toggleSidebar.Label() = %q, want %q", l, "Alt+B")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchOptionO is an integration-style test: it verifies that pressing
|
||||
// Option+O dispatches as toggleDetailed when configured.
|
||||
func TestDispatchOptionO(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
}
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
|
||||
// Simulate Option+O in iTerm2 with "Option as Meta" → ESC o → {Code:'o', Mod:ModAlt}
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
|
||||
defaultFn := func(tea.KeyMsg) bool { return false }
|
||||
|
||||
// This is the dispatch check the model uses
|
||||
m := model{keyBindings: bindings}
|
||||
if !m.keyMatch(bindings.toggleDetailed, msg, defaultFn) {
|
||||
t.Errorf("model.keyMatch should match option+o")
|
||||
}
|
||||
|
||||
// Also verify default doesn't match
|
||||
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
||||
if m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
|
||||
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// streamCoalesceInterval is roughly one 60fps frame. Assistant-text deltas that
|
||||
// arrive within this window are merged into a single agentTextMsg, so the render
|
||||
// rate decouples from the token rate: a fast local model (100+ tok/s) no longer
|
||||
// forces 100+ full Update→View cycles (each re-parsing the growing markdown) per
|
||||
// second. Rendering stays smooth regardless of provider speed.
|
||||
const streamCoalesceInterval = 16 * time.Millisecond
|
||||
|
||||
// textCoalescer batches agentTextMsg deltas before forwarding them to the Bubble
|
||||
// Tea program. Any OTHER message flushes the pending text first, so ordering
|
||||
// between streamed prose and tool-call / reasoning / row / usage messages is
|
||||
// preserved. The turn's final agentResponseMsg does not pass through here (it is
|
||||
// a tea.Cmd return, not a sink message), but the model drops deltas whose runID
|
||||
// is no longer active, so a flush that races just past end-of-turn is harmless.
|
||||
//
|
||||
// Sink messages originate from the single agent goroutine and so arrive
|
||||
// serially; the only concurrent caller is the flush timer. The mutex guards the
|
||||
// buffer/timer AND is held across the downstream forward, so a timer-fired text
|
||||
// flush can never overtake a concurrent non-text message: whoever holds the lock
|
||||
// drains and forwards atomically, and the other caller blocks until it is done.
|
||||
type textCoalescer struct {
|
||||
forward func(tea.Msg) // downstream sink (external sink + program.Send)
|
||||
// afterFunc schedules fn to run after one frame interval and returns a
|
||||
// stoppable timer. Defaults to a real time.AfterFunc(streamCoalesceInterval, …);
|
||||
// tests swap in a controllable timer so flush timing is deterministic instead of
|
||||
// racing the 16ms wall clock.
|
||||
afterFunc func(fn func()) coalesceTimer
|
||||
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
runID int
|
||||
timer coalesceTimer
|
||||
}
|
||||
|
||||
// coalesceTimer is the subset of *time.Timer the coalescer needs. Abstracted
|
||||
// behind afterFunc so a test can substitute a timer it controls.
|
||||
type coalesceTimer interface {
|
||||
Stop() bool
|
||||
}
|
||||
|
||||
func newTextCoalescer(forward func(tea.Msg)) *textCoalescer {
|
||||
return &textCoalescer{
|
||||
forward: forward,
|
||||
afterFunc: func(fn func()) coalesceTimer {
|
||||
return time.AfterFunc(streamCoalesceInterval, fn)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// send is the coalescing entry point installed as the RuntimeMessageSink.
|
||||
func (c *textCoalescer) send(msg tea.Msg) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
text, ok := msg.(agentTextMsg)
|
||||
if !ok {
|
||||
// Non-text message: flush buffered text first (preserving order), then
|
||||
// forward it — both under the lock so nothing can interleave between them.
|
||||
c.drainAndForwardLocked()
|
||||
c.forward(msg)
|
||||
return
|
||||
}
|
||||
|
||||
// A delta for a different run than the one buffered: flush the old run's text
|
||||
// before buffering the new run's. In practice runs are sequential (the prior
|
||||
// run's end already flushed via a non-text message), so this is belt-and-braces.
|
||||
if len(c.buf) > 0 && text.runID != c.runID {
|
||||
c.drainAndForwardLocked()
|
||||
}
|
||||
c.runID = text.runID
|
||||
c.buf = append(c.buf, text.delta...)
|
||||
if c.timer == nil {
|
||||
c.timer = c.afterFunc(c.flush)
|
||||
}
|
||||
}
|
||||
|
||||
// flush forwards any buffered text as one agentTextMsg. Runs on the timer
|
||||
// goroutine; the lock it takes serializes it against send so its output can't be
|
||||
// reordered around a concurrent non-text message.
|
||||
func (c *textCoalescer) flush() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.drainAndForwardLocked()
|
||||
}
|
||||
|
||||
// drainAndForwardLocked forwards any buffered text as one agentTextMsg and stops
|
||||
// the timer, all while the caller holds c.mu — so a text flush and any non-text
|
||||
// forward are strictly ordered and never interleave. A no-op when nothing is
|
||||
// buffered. string(c.buf) copies, so reusing the backing array via [:0] is safe.
|
||||
func (c *textCoalescer) drainAndForwardLocked() {
|
||||
if len(c.buf) == 0 {
|
||||
return
|
||||
}
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
c.timer = nil
|
||||
}
|
||||
msg := agentTextMsg{runID: c.runID, delta: string(c.buf)}
|
||||
c.buf = c.buf[:0]
|
||||
c.forward(msg)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// recorder is a thread-safe sink that captures forwarded messages in order.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
msgs []tea.Msg
|
||||
}
|
||||
|
||||
func (r *recorder) forward(msg tea.Msg) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.msgs = append(r.msgs, msg)
|
||||
}
|
||||
|
||||
func (r *recorder) snapshot() []tea.Msg {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]tea.Msg, len(r.msgs))
|
||||
copy(out, r.msgs)
|
||||
return out
|
||||
}
|
||||
|
||||
// manualTimer is a coalesceTimer that never fires on its own, so a test can prove
|
||||
// buffering deterministically without racing the real 16ms frame timer: the flush
|
||||
// happens only when the test calls c.flush() explicitly.
|
||||
type manualTimer struct{ stopped bool }
|
||||
|
||||
func (m *manualTimer) Stop() bool {
|
||||
prev := m.stopped
|
||||
m.stopped = true
|
||||
return !prev
|
||||
}
|
||||
|
||||
// Rapid deltas within one frame collapse into a single agentTextMsg carrying the
|
||||
// concatenated text.
|
||||
func TestCoalescerBatchesDeltas(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
// Swap in a timer that never auto-fires so the "still buffered" assertion below
|
||||
// cannot flake if the scheduler pauses this goroutine past the frame interval.
|
||||
c.afterFunc = func(func()) coalesceTimer { return &manualTimer{} }
|
||||
|
||||
c.send(agentTextMsg{runID: 1, delta: "Hel"})
|
||||
c.send(agentTextMsg{runID: 1, delta: "lo, "})
|
||||
c.send(agentTextMsg{runID: 1, delta: "world"})
|
||||
|
||||
// Nothing forwarded yet — still buffered within the frame (the timer we injected
|
||||
// never fires; only the explicit flush below delivers the text).
|
||||
if got := rec.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("deltas should buffer, forwarded %d early: %#v", len(got), got)
|
||||
}
|
||||
|
||||
c.flush()
|
||||
|
||||
got := rec.snapshot()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 coalesced message, got %d: %#v", len(got), got)
|
||||
}
|
||||
text, ok := got[0].(agentTextMsg)
|
||||
if !ok || text.delta != "Hello, world" || text.runID != 1 {
|
||||
t.Fatalf("coalesced message = %#v, want agentTextMsg{1, \"Hello, world\"}", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A non-text message flushes buffered text first, so ordering (text before the
|
||||
// tool call it precedes) is preserved.
|
||||
func TestCoalescerFlushesTextBeforeOtherMessages(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
|
||||
c.send(agentTextMsg{runID: 1, delta: "about to run"})
|
||||
c.send(toolCallStreamStartMsg{runID: 1, id: "t1", name: "bash"})
|
||||
|
||||
got := rec.snapshot()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected text then tool-call, got %d: %#v", len(got), got)
|
||||
}
|
||||
if text, ok := got[0].(agentTextMsg); !ok || text.delta != "about to run" {
|
||||
t.Fatalf("first forwarded message must be the flushed text, got %#v", got[0])
|
||||
}
|
||||
if _, ok := got[1].(toolCallStreamStartMsg); !ok {
|
||||
t.Fatalf("second forwarded message must be the tool-call start, got %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// A delta for a new run flushes the previous run's buffered text before buffering
|
||||
// the new run's, so text is never mis-attributed across runs.
|
||||
func TestCoalescerFlushesOnRunSwitch(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
|
||||
c.send(agentTextMsg{runID: 1, delta: "run one text"})
|
||||
c.send(agentTextMsg{runID: 2, delta: "run two text"})
|
||||
c.flush()
|
||||
|
||||
got := rec.snapshot()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected one message per run, got %d: %#v", len(got), got)
|
||||
}
|
||||
if first := got[0].(agentTextMsg); first.runID != 1 || first.delta != "run one text" {
|
||||
t.Fatalf("first message = %#v, want run 1 text flushed on switch", got[0])
|
||||
}
|
||||
if second := got[1].(agentTextMsg); second.runID != 2 || second.delta != "run two text" {
|
||||
t.Fatalf("second message = %#v, want run 2 text", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Under concurrency (the frame timer firing while a non-text message is sent),
|
||||
// forwarding must stay serialized and ordered: buffered text is always delivered
|
||||
// before a following non-text message, never after it. Run with -race to catch
|
||||
// the interleaving CodeRabbit flagged.
|
||||
func TestCoalescerOrdersTextBeforeNonTextUnderConcurrency(t *testing.T) {
|
||||
for iter := 0; iter < 200; iter++ {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
|
||||
// Buffer text, then let the timer race against an inline non-text send.
|
||||
c.send(agentTextMsg{runID: 1, delta: "before-tool"})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.send(toolCallStreamStartMsg{runID: 1, id: "t1", name: "bash"})
|
||||
close(done)
|
||||
}()
|
||||
<-done
|
||||
|
||||
// Give the timer a chance to also fire, then settle.
|
||||
time.Sleep(2 * streamCoalesceInterval)
|
||||
c.flush()
|
||||
|
||||
got := rec.snapshot()
|
||||
textIdx, toolIdx := -1, -1
|
||||
for i, m := range got {
|
||||
switch m.(type) {
|
||||
case agentTextMsg:
|
||||
if textIdx == -1 {
|
||||
textIdx = i
|
||||
}
|
||||
case toolCallStreamStartMsg:
|
||||
toolIdx = i
|
||||
}
|
||||
}
|
||||
if textIdx == -1 || toolIdx == -1 {
|
||||
t.Fatalf("iter %d: missing messages: %#v", iter, got)
|
||||
}
|
||||
if textIdx > toolIdx {
|
||||
t.Fatalf("iter %d: text (%d) forwarded after tool-call (%d): %#v", iter, textIdx, toolIdx, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The frame timer flushes buffered text on its own without an explicit flush or a
|
||||
// following message.
|
||||
func TestCoalescerTimerFlushes(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
|
||||
c.send(agentTextMsg{runID: 1, delta: "timer-driven"})
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(rec.snapshot()) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(streamCoalesceInterval)
|
||||
}
|
||||
|
||||
got := rec.snapshot()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("timer should have flushed exactly one message, got %d: %#v", len(got), got)
|
||||
}
|
||||
if text := got[0].(agentTextMsg); text.delta != "timer-driven" {
|
||||
t.Fatalf("timer-flushed message = %#v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -482,6 +482,9 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
lines = append(lines, target.notice)
|
||||
}
|
||||
lines = append(lines, summary)
|
||||
if warn := m.visionDropWarning(); warn != "" {
|
||||
lines = append(lines, warn)
|
||||
}
|
||||
return m, strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -536,7 +539,11 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model), tea.Batch(cmds...)
|
||||
status := fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model)
|
||||
if warn := m.visionDropWarning(); warn != "" {
|
||||
status += "\n" + warn
|
||||
}
|
||||
return m, status, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// profileWithCredential fills a profile's APIKey for provider construction the same
|
||||
|
||||
@@ -377,7 +377,7 @@ func TestContextCommandCardHandlesNilRegistryAndStableStyle(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Context",
|
||||
"0 tools",
|
||||
"style balanced",
|
||||
"style concise",
|
||||
"root unknown",
|
||||
} {
|
||||
assertContains(t, text, want)
|
||||
|
||||
@@ -559,6 +559,21 @@ func (m model) modelText(args string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// avgTurnLatencyText reports the session's rolling average turn wall-time for
|
||||
// /context — the "is it slow?" signal a user otherwise can only feel. "n/a" until
|
||||
// a turn has completed.
|
||||
func (m model) avgTurnLatencyText() string {
|
||||
if m.turnLatencyCount == 0 {
|
||||
return "n/a"
|
||||
}
|
||||
avgSeconds := m.turnLatencySum.Seconds() / float64(m.turnLatencyCount)
|
||||
if m.turnTTFTCount > 0 {
|
||||
ttftSeconds := m.turnTTFTSum.Seconds() / float64(m.turnTTFTCount)
|
||||
return fmt.Sprintf("%.1fs avg (%.1fs to first token, %d turns)", avgSeconds, ttftSeconds, m.turnLatencyCount)
|
||||
}
|
||||
return fmt.Sprintf("%.1fs avg (%d turns)", avgSeconds, m.turnLatencyCount)
|
||||
}
|
||||
|
||||
func (m model) contextText() string {
|
||||
toolCount := len(m.registeredTools())
|
||||
return renderCommandCardTranscript(commandCard{
|
||||
@@ -578,6 +593,8 @@ func (m model) contextText() string {
|
||||
{Key: "effort", Value: m.effortDisplay()},
|
||||
{Key: "style", Value: displayValue(m.responseStyle, defaultResponseStyle)},
|
||||
{Key: "usage", Value: m.usageSummaryText()},
|
||||
{Key: "cache", Value: m.cacheEfficiencyText()},
|
||||
{Key: "latency", Value: m.avgTurnLatencyText()},
|
||||
{Key: "max turns", Value: fmt.Sprint(m.agentOptions.MaxTurns)},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,6 +41,11 @@ const (
|
||||
commandAddDir
|
||||
commandSelfCorrect
|
||||
commandTurns
|
||||
commandRetry
|
||||
commandEdit
|
||||
commandCopy
|
||||
commandExport
|
||||
commandNew
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -154,6 +159,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Clear the visible transcript.",
|
||||
kind: commandClear,
|
||||
},
|
||||
{
|
||||
name: "/new",
|
||||
usage: "/new",
|
||||
group: commandGroupSession,
|
||||
description: "Start a fresh session; the current one stays resumable via /resume.",
|
||||
kind: commandNew,
|
||||
},
|
||||
{
|
||||
name: "/search",
|
||||
aliases: []string{"/find"},
|
||||
@@ -201,9 +213,9 @@ var commandDefinitions = []commandDefinition{
|
||||
},
|
||||
{
|
||||
name: "/compact",
|
||||
usage: "/compact [status]",
|
||||
usage: "/compact [status|now]",
|
||||
group: commandGroupSession,
|
||||
description: "Show or request transcript compaction state.",
|
||||
description: "Compact the transcript now, or show compaction state (/compact status).",
|
||||
kind: commandCompact,
|
||||
},
|
||||
{
|
||||
@@ -249,6 +261,34 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Show or set the per-run tool-turn budget for this session (raise it for long multi-step tasks).",
|
||||
kind: commandTurns,
|
||||
},
|
||||
{
|
||||
name: "/retry",
|
||||
usage: "/retry",
|
||||
group: commandGroupSession,
|
||||
description: "Resend your last prompt.",
|
||||
kind: commandRetry,
|
||||
},
|
||||
{
|
||||
name: "/edit",
|
||||
usage: "/edit",
|
||||
group: commandGroupSession,
|
||||
description: "Recall your last prompt into the composer to edit and resend.",
|
||||
kind: commandEdit,
|
||||
},
|
||||
{
|
||||
name: "/copy",
|
||||
usage: "/copy",
|
||||
group: commandGroupSession,
|
||||
description: "Copy the last answer to the clipboard.",
|
||||
kind: commandCopy,
|
||||
},
|
||||
{
|
||||
name: "/export",
|
||||
usage: "/export [path]",
|
||||
group: commandGroupSession,
|
||||
description: "Write the conversation transcript to a file.",
|
||||
kind: commandExport,
|
||||
},
|
||||
{
|
||||
name: "/help",
|
||||
usage: "/help",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "/compact now" is accepted as a manual trigger (not a usage error), while a
|
||||
// bogus argument still reports usage.
|
||||
func TestCompactNowAccepted(t *testing.T) {
|
||||
m := model{}
|
||||
|
||||
_, nowText, _ := m.handleCompactCommand("now")
|
||||
if strings.Contains(nowText, "usage:") {
|
||||
t.Fatalf("/compact now must be accepted as a trigger, got usage error: %q", nowText)
|
||||
}
|
||||
|
||||
_, statusText, _ := m.handleCompactCommand("status")
|
||||
if strings.Contains(statusText, "usage:") {
|
||||
t.Fatalf("/compact status must not error, got: %q", statusText)
|
||||
}
|
||||
|
||||
_, bogusText, _ := m.handleCompactCommand("frobnicate")
|
||||
if !strings.Contains(bogusText, "usage:") {
|
||||
t.Fatalf("/compact frobnicate must report usage, got: %q", bogusText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A hinted error row renders the raw error and the faint one-line next step
|
||||
// below it; an unhinted row renders just the error.
|
||||
func TestRenderErrorRowShowsHint(t *testing.T) {
|
||||
withHint := renderErrorRow(transcriptRow{
|
||||
kind: rowError,
|
||||
text: "auth error: your API key is missing or invalid",
|
||||
hint: "API key rejected — run /provider to re-check your credentials",
|
||||
}, 80)
|
||||
if !strings.Contains(withHint, "auth error:") {
|
||||
t.Fatalf("expected raw error text in output, got:\n%s", withHint)
|
||||
}
|
||||
if !strings.Contains(withHint, "/provider") {
|
||||
t.Fatalf("expected hint referencing /provider, got:\n%s", withHint)
|
||||
}
|
||||
|
||||
noHint := renderErrorRow(transcriptRow{kind: rowError, text: "provider error: mystery"}, 80)
|
||||
if strings.Contains(noHint, "→") {
|
||||
t.Fatalf("unhinted error must not render a hint arrow, got:\n%s", noHint)
|
||||
}
|
||||
}
|
||||
@@ -105,11 +105,14 @@ func TestClearResetsFlushFrontier(t *testing.T) {
|
||||
m.input.SetValue("/clear")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected /clear to reset transcript, got %#v", next.transcript)
|
||||
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected /clear to reset transcript to welcome + note, got %#v", next.transcript)
|
||||
}
|
||||
if next.flushed > 1 {
|
||||
t.Fatalf("expected frontier reset after /clear, got %d", next.flushed)
|
||||
if !transcriptContains(next.transcript, "/new") {
|
||||
t.Fatalf("expected /clear to point users to /new, got %#v", next.transcript)
|
||||
}
|
||||
if next.flushed != len(next.transcript) {
|
||||
t.Fatalf("expected frontier reset to match cleared transcript (%d rows), got %d", len(next.transcript), next.flushed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +121,7 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
|
||||
m.pending = true
|
||||
m.activeRunID = 5
|
||||
m.runCancel = func() {}
|
||||
m.streamingText = "half an answer"
|
||||
m.streamingText = []byte("half an answer")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
@@ -130,7 +133,7 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
|
||||
if !transcriptContains(next.transcript, "half an answer") {
|
||||
t.Fatalf("expected the partial streamed answer to be preserved, got %#v", next.transcript)
|
||||
}
|
||||
if next.streamingText != "" {
|
||||
if len(next.streamingText) != 0 {
|
||||
t.Fatal("streaming text should be cleared after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHumanCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{999, "999"},
|
||||
{1000, "1K"},
|
||||
{12400, "12.4K"},
|
||||
{200000, "200K"},
|
||||
{999999, "1000K"},
|
||||
// The bug: a million-scale count must switch to an M suffix instead
|
||||
// of continuing to render as "1000K", "1200K", etc.
|
||||
{1_000_000, "1M"},
|
||||
{1_048_576, "1M"},
|
||||
{1_200_000, "1.2M"},
|
||||
{-5, "0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := humanCount(c.n); got != c.want {
|
||||
t.Errorf("humanCount(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,18 @@ func renderImageChips(labels []string) string {
|
||||
// and staged documents, e.g. "[Image #1] [Image #2] [Doc #1]". Returns "" when
|
||||
// nothing is staged. Numbered (not named) so a long screenshot path never shows
|
||||
// in the composer.
|
||||
// visionDropWarning returns a one-line notice when images are staged but the
|
||||
// (now active) model can't accept them, so switching to a non-vision model warns
|
||||
// the user immediately at switch time instead of silently dropping the images at
|
||||
// submit. Empty when there is nothing staged or the model supports vision.
|
||||
func (m model) visionDropWarning() string {
|
||||
if len(m.pendingImages) == 0 || m.modelSupportsVisionTUI() {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("⚠ %d staged image(s) will be dropped — %s has no vision support.",
|
||||
len(m.pendingImages), displayValue(m.modelName, "the active model"))
|
||||
}
|
||||
|
||||
func renderAttachmentChips(imageLabels []string, docs []pendingDocument) string {
|
||||
chips := make([]string, 0, len(imageLabels)+len(docs))
|
||||
for i := range imageLabels {
|
||||
|
||||
@@ -483,3 +483,76 @@ func TestRenderAttachmentChips(t *testing.T) {
|
||||
t.Fatalf("chip row %q should not show file names", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryResendsAttachments guards the /retry contract: it must resend the exact
|
||||
// request the last prompt carried — including its staged image and PDF document —
|
||||
// not a degraded text-only prompt. launchPrompt clears the pending queues once a
|
||||
// turn is sent, so without re-staging the remembered snapshot a vision/document-
|
||||
// backed turn that failed would silently retry as a different task.
|
||||
func TestRetryResendsAttachments(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
captured := make(chan []zeroruntime.ImageBlock, 1)
|
||||
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventText, Content: "ok"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: root,
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-4.1",
|
||||
Provider: provider,
|
||||
Registry: tools.NewRegistry(),
|
||||
SessionStore: testSessionStore(t),
|
||||
})
|
||||
m.agentOptions.OnText = func(string) {}
|
||||
m.captureRunImages = func(imgs []zeroruntime.ImageBlock) { captured <- imgs }
|
||||
|
||||
// State left after a prior vision+PDF prompt was submitted: the pending queues
|
||||
// are cleared, but the remembered snapshot survives so /retry can reproduce it.
|
||||
m.lastPrompt = "describe both"
|
||||
m.lastImages = []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{0x89, 'P', 'N', 'G'}}}
|
||||
m.lastImageLabels = []string{"photo.png"}
|
||||
m.lastDocuments = []pendingDocument{{label: "spec.pdf", text: "Top secret design notes"}}
|
||||
|
||||
m.input.SetValue("/retry")
|
||||
updated, cmd := m.handleSubmit()
|
||||
next := updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("/retry with a remembered prompt should launch a run")
|
||||
}
|
||||
// The retried turn re-consumes and then clears the queues, exactly like a fresh
|
||||
// submit; the snapshot must survive so a second /retry stays reproducible.
|
||||
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 || len(next.pendingDocuments) != 0 {
|
||||
t.Fatalf("retry must clear pending queues after resend, got imgs=%d labels=%d docs=%d",
|
||||
len(next.pendingImages), len(next.pendingImageLabels), len(next.pendingDocuments))
|
||||
}
|
||||
if len(next.lastImages) != 1 || len(next.lastDocuments) != 1 {
|
||||
t.Fatalf("retry must keep the snapshot for a subsequent retry, got imgs=%d docs=%d",
|
||||
len(next.lastImages), len(next.lastDocuments))
|
||||
}
|
||||
|
||||
updated, _ = next.Update(execCmd(cmd))
|
||||
_ = updated.(model)
|
||||
|
||||
select {
|
||||
case imgs := <-captured:
|
||||
if len(imgs) != 1 || imgs[0].MediaType != "image/png" {
|
||||
t.Fatalf("retried run must resend the remembered image, got %#v", imgs)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected the retried run to receive the remembered image")
|
||||
}
|
||||
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected one provider request, got %d", len(provider.requests))
|
||||
}
|
||||
msgs := provider.requests[0].Messages
|
||||
last := msgs[len(msgs)-1]
|
||||
if !strings.Contains(last.Content, "Top secret design notes") {
|
||||
t.Fatalf("retried prompt should re-prepend the document text, got:\n%s", last.Content)
|
||||
}
|
||||
if !strings.Contains(last.Content, "describe both") {
|
||||
t.Fatalf("retried prompt should include the remembered user text, got:\n%s", last.Content)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
// lists them grouped, so the keymap is discoverable the way the reference TUIs
|
||||
// do it. The list is declarative and hand-curated to match the real handlers in
|
||||
// model.go's Update switch; keep them in sync when a binding changes.
|
||||
//
|
||||
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
|
||||
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
|
||||
// who remaps them in config.json sees the actual chords, not the defaults.
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
@@ -21,63 +25,67 @@ type keybindingGroup struct {
|
||||
bindings []keybinding
|
||||
}
|
||||
|
||||
// keybindingGroups is the full, grouped shortcut list shown by the `?` overlay.
|
||||
// buildKeybindingGroups returns the full, grouped shortcut list shown by the
|
||||
// `?` overlay. Configurable bindings use the user's remapped labels (via
|
||||
// m.keyBindings) when present; unconfigured bindings get their built-in labels.
|
||||
// Sourced from the real key cases in model.go (Update) — not invented. When a
|
||||
// binding is added/changed there, update it here too (a test guards that the
|
||||
// data is non-empty and well-formed, but it can't verify the bindings exist).
|
||||
var keybindingGroups = []keybindingGroup{
|
||||
{
|
||||
title: "Chat",
|
||||
bindings: []keybinding{
|
||||
{"Enter", "send the message"},
|
||||
{"Alt+Enter", "insert a newline (multi-line compose)"},
|
||||
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
// binding is added/changed there, update this method too.
|
||||
func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
return []keybindingGroup{
|
||||
{
|
||||
title: "Chat",
|
||||
bindings: []keybinding{
|
||||
{"Enter", "send the message"},
|
||||
{"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"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Model & run controls",
|
||||
bindings: []keybinding{
|
||||
{"Ctrl+T", "cycle reasoning effort (auto → low → medium → high)"},
|
||||
{"Shift+Tab", "cycle permission mode (auto ↔ ask)"},
|
||||
{"Ctrl+P", "expand / collapse the plan panel"},
|
||||
{
|
||||
title: "Model & run controls",
|
||||
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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Navigation & scrollback",
|
||||
bindings: []keybinding{
|
||||
{"PgUp / PgDn", "scroll the transcript by a page"},
|
||||
{"↑ / ↓", "scroll, or move within a popup / multi-line input"},
|
||||
{"Ctrl+O", "toggle the detailed (full-screen) transcript"},
|
||||
{"Ctrl+B", "hide / show the right context sidebar"},
|
||||
{"Ctrl+E", "release the mouse to drag-select & copy text"},
|
||||
{"Tab", "accept the autocomplete / picker selection"},
|
||||
{
|
||||
title: "Navigation & scrollback",
|
||||
bindings: []keybinding{
|
||||
{"PgUp / PgDn", "scroll the transcript by a page"},
|
||||
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
|
||||
{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"},
|
||||
{"Tab", "accept the autocomplete / picker selection"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Specialists & pickers",
|
||||
bindings: []keybinding{
|
||||
{"Click a specialist card", "drill into its sub-session"},
|
||||
{"↑ / Esc (in a sub-session)", "return to the main chat"},
|
||||
{"Ctrl+F (in /model)", "toggle the highlighted model as a favorite"},
|
||||
{"Click a tool card", "expand / collapse its output"},
|
||||
{"Right-click", "paste the clipboard"},
|
||||
{
|
||||
title: "Specialists & pickers",
|
||||
bindings: []keybinding{
|
||||
{"Click a specialist card", "drill into its sub-session"},
|
||||
{"\u2191 / Esc (in a sub-session)", "return to the main chat"},
|
||||
{"Ctrl+F (in /model)", "toggle the highlighted model as a favorite"},
|
||||
{"Click a tool card", "expand / collapse its output"},
|
||||
{"Right-click", "paste the clipboard"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// keybindingHelpFooter is the dismiss hint shown at the bottom of the overlay.
|
||||
const keybindingHelpFooter = "? or Esc to close · /help for slash commands"
|
||||
const keybindingHelpFooter = "? or Esc to close \u00b7 /help for slash commands"
|
||||
|
||||
// renderKeybindingHelpLines builds the overlay body lines (group titles +
|
||||
// aligned key/description rows + footer), wrapped to the inner width. Exposed
|
||||
// separately from the framed renderer so tests can assert on content without
|
||||
// the border chrome.
|
||||
func (m model) renderKeybindingHelpLines(innerWidth int) []string {
|
||||
keyColumn := keybindingKeyColumnWidth()
|
||||
groups := m.buildKeybindingGroups()
|
||||
keyColumn := keybindingKeyColumnWidth(groups)
|
||||
lines := make([]string, 0, 64)
|
||||
for index, group := range keybindingGroups {
|
||||
for index, group := range groups {
|
||||
if index > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
@@ -93,9 +101,9 @@ func (m model) renderKeybindingHelpLines(innerWidth int) []string {
|
||||
|
||||
// keybindingKeyColumnWidth returns the width of the key column: the widest key
|
||||
// chord across all groups, so the descriptions align in a clean second column.
|
||||
func keybindingKeyColumnWidth() int {
|
||||
func keybindingKeyColumnWidth(groups []keybindingGroup) int {
|
||||
widest := 0
|
||||
for _, group := range keybindingGroups {
|
||||
for _, group := range groups {
|
||||
for _, binding := range group.bindings {
|
||||
if n := len([]rune(binding.keys)); n > widest {
|
||||
widest = n
|
||||
|
||||
@@ -87,11 +87,13 @@ func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeybindingGroupsAreWellFormed(t *testing.T) {
|
||||
if len(keybindingGroups) == 0 {
|
||||
t.Fatal("keybindingGroups must not be empty")
|
||||
func TestBuildKeybindingGroupsAreWellFormed(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
groups := m.buildKeybindingGroups()
|
||||
if len(groups) == 0 {
|
||||
t.Fatal("buildKeybindingGroups must not return empty")
|
||||
}
|
||||
for _, group := range keybindingGroups {
|
||||
for _, group := range groups {
|
||||
if strings.TrimSpace(group.title) == "" {
|
||||
t.Fatal("every keybinding group needs a title")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// parsedBinding is the parsed representation of a keybinding string such as
|
||||
// "ctrl+o" or "alt+enter". The zero value is a sentinel meaning "use default".
|
||||
type parsedBinding struct {
|
||||
ctrl bool
|
||||
alt bool
|
||||
shift bool
|
||||
cmd bool // ⌘ Command (macOS) / ⊞ Win → ModSuper
|
||||
code rune // tea.KeyCode or 0 for text-based matching
|
||||
text string // for text-based matching (e.g. "?")
|
||||
}
|
||||
|
||||
// isZero returns true when p is the nil sentinel (no binding configured).
|
||||
func (p parsedBinding) isZero() bool {
|
||||
return p.code == 0 && p.text == ""
|
||||
}
|
||||
|
||||
// Label returns a human-readable representation of the binding, e.g. "Ctrl+O"
|
||||
// or "Cmd+Shift+Enter". Used in the help overlay. Returns empty string for
|
||||
// zero (unset) bindings.
|
||||
func (p parsedBinding) Label() string {
|
||||
if p.isZero() {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
if p.ctrl {
|
||||
b.WriteString("Ctrl+")
|
||||
}
|
||||
if p.alt {
|
||||
b.WriteString("Alt+")
|
||||
}
|
||||
if p.shift {
|
||||
b.WriteString("Shift+")
|
||||
}
|
||||
if p.cmd {
|
||||
b.WriteString("Cmd+")
|
||||
}
|
||||
if p.text != "" {
|
||||
b.WriteString(p.text)
|
||||
} else if p.code != 0 {
|
||||
switch p.code {
|
||||
case tea.KeyEnter:
|
||||
b.WriteString("Enter")
|
||||
case tea.KeyTab:
|
||||
b.WriteString("Tab")
|
||||
case tea.KeyEsc:
|
||||
b.WriteString("Esc")
|
||||
case tea.KeySpace:
|
||||
b.WriteString("Space")
|
||||
case tea.KeyBackspace:
|
||||
b.WriteString("Backspace")
|
||||
case tea.KeyDelete:
|
||||
b.WriteString("Delete")
|
||||
case tea.KeyUp:
|
||||
b.WriteString("↑")
|
||||
case tea.KeyDown:
|
||||
b.WriteString("↓")
|
||||
case tea.KeyLeft:
|
||||
b.WriteString("←")
|
||||
case tea.KeyRight:
|
||||
b.WriteString("→")
|
||||
case tea.KeyHome:
|
||||
b.WriteString("Home")
|
||||
case tea.KeyEnd:
|
||||
b.WriteString("End")
|
||||
case tea.KeyPgUp:
|
||||
b.WriteString("PgUp")
|
||||
case tea.KeyPgDown:
|
||||
b.WriteString("PgDn")
|
||||
default:
|
||||
// Printable character — uppercase for display
|
||||
if p.code >= 'a' && p.code <= 'z' {
|
||||
b.WriteRune(p.code - 32)
|
||||
} else {
|
||||
b.WriteRune(p.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Matcher returns a function that matches a tea.KeyMsg against this binding.
|
||||
// It is the hot path for the key dispatch — kept cheap intentionally.
|
||||
func (p parsedBinding) Matcher() func(tea.KeyMsg) bool {
|
||||
if p.isZero() {
|
||||
// A zero binding should never be matched — the caller is expected to
|
||||
// check useDefault() first and fall through to the built-in check.
|
||||
return func(tea.KeyMsg) bool { return false }
|
||||
}
|
||||
|
||||
// Build the required modifier mask from the parsed flags.
|
||||
var mod tea.KeyMod
|
||||
if p.ctrl {
|
||||
mod |= tea.ModCtrl
|
||||
}
|
||||
if p.alt {
|
||||
mod |= tea.ModAlt
|
||||
}
|
||||
if p.shift {
|
||||
mod |= tea.ModShift
|
||||
}
|
||||
if p.cmd {
|
||||
mod |= tea.ModSuper // ⌘ Command on macOS, ⊞ Win on Windows
|
||||
}
|
||||
|
||||
// ctrl+letter fast path — use exact modifier equality so that a
|
||||
// configured ctrl+o does NOT fire on ctrl+alt+o or ctrl+shift+o.
|
||||
// Note: in raw terminal mode Bubble Tea may report ctrl+letter as
|
||||
// {Code:letter, Mod:ModCtrl} (handled below), or as a control char
|
||||
// code (e.g. 0x0F) without a modifier flag. The latter is handled by
|
||||
// the text-based fallback in model.go for default bindings; for
|
||||
// configured bindings the code+mod path below is what the user
|
||||
// expressed.
|
||||
if mod == tea.ModCtrl && p.code >= 'a' && p.code <= 'z' {
|
||||
return func(msg tea.KeyMsg) bool {
|
||||
return msg.Key().Code == p.code && msg.Key().Mod == mod
|
||||
}
|
||||
}
|
||||
|
||||
if p.text != "" {
|
||||
return func(msg tea.KeyMsg) bool {
|
||||
return msg.Key().Text == p.text && msg.Key().Mod == mod
|
||||
}
|
||||
}
|
||||
|
||||
return func(msg tea.KeyMsg) bool {
|
||||
return msg.Key().Code == p.code && msg.Key().Mod == mod
|
||||
}
|
||||
}
|
||||
|
||||
// parseBinding converts a user-written keybinding string (e.g. "ctrl+o") into
|
||||
// a parsedBinding. The empty string returns zero parsedBinding (the "use
|
||||
// default" sentinel).
|
||||
func parseBinding(s string) parsedBinding {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return parsedBinding{}
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.ToLower(s), "+")
|
||||
var p parsedBinding
|
||||
var keyPart string
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
switch part {
|
||||
case "ctrl", "control":
|
||||
p.ctrl = true
|
||||
case "alt", "option":
|
||||
p.alt = true
|
||||
case "shift":
|
||||
p.shift = true
|
||||
case "cmd", "command":
|
||||
p.cmd = true
|
||||
case "super":
|
||||
// "super" matches the Bubble Tea naming; on macOS this is ⌘
|
||||
p.cmd = true
|
||||
default:
|
||||
keyPart = part
|
||||
}
|
||||
}
|
||||
|
||||
if p.ctrl && len(keyPart) == 1 && keyPart[0] >= 'a' && keyPart[0] <= 'z' {
|
||||
// ctrl+<letter> — store the code so the match is exact
|
||||
p.code = []rune(keyPart)[0]
|
||||
p.text = ""
|
||||
return p
|
||||
}
|
||||
|
||||
// Map named keys to their tea.KeyCode
|
||||
switch keyPart {
|
||||
case "enter", "return":
|
||||
p.code = tea.KeyEnter
|
||||
case "tab":
|
||||
p.code = tea.KeyTab
|
||||
case "esc", "escape":
|
||||
p.code = tea.KeyEsc
|
||||
case "space":
|
||||
p.code = tea.KeySpace
|
||||
case "backspace":
|
||||
p.code = tea.KeyBackspace
|
||||
case "delete":
|
||||
p.code = tea.KeyDelete
|
||||
case "up":
|
||||
p.code = tea.KeyUp
|
||||
case "down":
|
||||
p.code = tea.KeyDown
|
||||
case "left":
|
||||
p.code = tea.KeyLeft
|
||||
case "right":
|
||||
p.code = tea.KeyRight
|
||||
case "home":
|
||||
p.code = tea.KeyHome
|
||||
case "end":
|
||||
p.code = tea.KeyEnd
|
||||
case "pgup", "pageup":
|
||||
p.code = tea.KeyPgUp
|
||||
case "pgdown", "pagedown":
|
||||
p.code = tea.KeyPgDown
|
||||
case "?":
|
||||
p.text = "?"
|
||||
p.code = 0
|
||||
default:
|
||||
// Single character, any modifier context
|
||||
if utf8.RuneCountInString(keyPart) == 1 {
|
||||
p.code = []rune(keyPart)[0]
|
||||
}
|
||||
// else unrecognised — leave zero so it falls through to default
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// labelOr returns b.Label() when b is configured (non-zero), otherwise it
|
||||
// returns the caller-supplied default label string. This is the display-layer
|
||||
// counterpart to keyMatch — dispatch falls through to the hardcoded default
|
||||
// function, so the label displayed in help / hints must match that fallback.
|
||||
func labelOr(b parsedBinding, defaultLabel string) string {
|
||||
if !b.isZero() {
|
||||
return b.Label()
|
||||
}
|
||||
return defaultLabel
|
||||
}
|
||||
|
||||
// keyBindings holds the parsed, resolved key bindings the model uses at
|
||||
// dispatch time. When a binding's parsedBinding is zero, the built-in default
|
||||
// check in model.go's updateModel should be used.
|
||||
type keyBindings struct {
|
||||
toggleDetailed parsedBinding
|
||||
toggleMouse parsedBinding
|
||||
cycleReasoning parsedBinding
|
||||
togglePlan parsedBinding
|
||||
toggleSidebar parsedBinding
|
||||
}
|
||||
|
||||
// resolveKeyBindings converts a user-facing KeyBindingsConfig into the
|
||||
// dispatch-ready parsed form, using empty-is-default semantics.
|
||||
func resolveKeyBindings(cfg config.KeyBindingsConfig) keyBindings {
|
||||
return keyBindings{
|
||||
toggleDetailed: parseBinding(string(cfg.ToggleDetailed)),
|
||||
toggleMouse: parseBinding(string(cfg.ToggleMouse)),
|
||||
cycleReasoning: parseBinding(string(cfg.CycleReasoning)),
|
||||
togglePlan: parseBinding(string(cfg.TogglePlan)),
|
||||
toggleSidebar: parseBinding(string(cfg.ToggleSidebar)),
|
||||
}
|
||||
}
|
||||
|
||||
// keyMatch returns true when msg matches either the user-configured binding b
|
||||
// or (when b is zero/unset) the built-in default matcher defaultFn. This is
|
||||
// the bridge between the config surface and the hot dispatch path in model.go.
|
||||
func (m model) keyMatch(b parsedBinding, msg tea.KeyMsg, defaultFn func(tea.KeyMsg) bool) bool {
|
||||
if !b.isZero() {
|
||||
return b.Matcher()(msg)
|
||||
}
|
||||
return defaultFn(msg)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func TestAvgTurnLatencyText(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
if got := m.avgTurnLatencyText(); got != "n/a" {
|
||||
t.Fatalf("empty latency = %q, want n/a", got)
|
||||
}
|
||||
m.turnLatencySum = 9 * time.Second
|
||||
m.turnLatencyCount = 2
|
||||
if got := m.avgTurnLatencyText(); got != "4.5s avg (2 turns)" {
|
||||
t.Fatalf("avgTurnLatencyText = %q, want \"4.5s avg (2 turns)\"", got)
|
||||
}
|
||||
// With TTFT recorded, the line also reports time-to-first-token.
|
||||
m.turnTTFTSum = 3 * time.Second
|
||||
m.turnTTFTCount = 2
|
||||
if got := m.avgTurnLatencyText(); got != "4.5s avg (1.5s to first token, 2 turns)" {
|
||||
t.Fatalf("avgTurnLatencyText with ttft = %q, want \"4.5s avg (1.5s to first token, 2 turns)\"", got)
|
||||
}
|
||||
// /new must reset the rolling latency + ttft so a fresh session starts from zero.
|
||||
m.activeSession = sessions.Metadata{SessionID: "x"}
|
||||
next := m.startNewSession()
|
||||
if next.turnLatencyCount != 0 || next.turnLatencySum != 0 || next.turnTTFTCount != 0 || next.turnTTFTSum != 0 {
|
||||
t.Fatalf("startNewSession must reset latency+ttft, got latency=%d/%v ttft=%d/%v", next.turnLatencyCount, next.turnLatencySum, next.turnTTFTCount, next.turnTTFTSum)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The LSP manager is built once at construction (when cwd is known) and reused —
|
||||
// a fresh manager per run would cold-start gopls on the first edit of every turn.
|
||||
func TestNewModelBuildsSharedLSPManager(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Cwd: t.TempDir()})
|
||||
if m.lspManager == nil {
|
||||
t.Fatal("expected a session-long lspManager when cwd is set")
|
||||
}
|
||||
// The pointer is stable across model copies (Bubble Tea passes the model by
|
||||
// value every Update), so every run shares the same warm servers.
|
||||
copied := m
|
||||
if copied.lspManager != m.lspManager {
|
||||
t.Fatal("lspManager pointer must survive a model copy")
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownLSPManager must be nil-safe: a model built without a cwd has no manager,
|
||||
// and quitting it must not panic.
|
||||
func TestShutdownLSPManagerNilSafe(t *testing.T) {
|
||||
m := model{}
|
||||
if m.lspManager != nil {
|
||||
t.Fatal("expected no manager on a zero-value model")
|
||||
}
|
||||
m.shutdownLSPManager() // must not panic
|
||||
}
|
||||
+250
-75
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/doctor"
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
internalmcp "github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -36,7 +37,7 @@ import (
|
||||
)
|
||||
|
||||
const tuiToolOutputLimit = 240
|
||||
const defaultResponseStyle = "balanced"
|
||||
const defaultResponseStyle = "concise"
|
||||
const chatWheelScrollLines = 5
|
||||
const ctrlCExitConfirmDuration = 3 * time.Second
|
||||
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
|
||||
@@ -75,24 +76,29 @@ type model struct {
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
|
||||
registry *tools.Registry
|
||||
sessionStore *sessions.Store
|
||||
sandboxStore *sandbox.GrantStore
|
||||
mcpConfig config.MCPConfig
|
||||
mcpPermissionStore *internalmcp.PermissionStore
|
||||
mcpTokenStore *internalmcp.TokenStore
|
||||
mcpCommand func(context.Context, []string) MCPCommandResult
|
||||
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
mcpViewStateCache MCPViewState
|
||||
mcpViewStateReady bool
|
||||
mcpCommandSeq int
|
||||
mcpCommandCancel context.CancelFunc
|
||||
sandboxSetupSeq int
|
||||
sandboxSetupInFlight bool
|
||||
doctorCommandSeq int
|
||||
doctorInFlight bool
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
// lspManager is created once per session and reused across prompts so gopls (and
|
||||
// other language servers) stay warm — a fresh manager per run would cold-start
|
||||
// the server on the first edit of every turn. Nil when cwd is unknown; runs then
|
||||
// fall back to a per-run manager. Torn down in quit().
|
||||
lspManager *lsp.Manager
|
||||
sessionStore *sessions.Store
|
||||
sandboxStore *sandbox.GrantStore
|
||||
mcpConfig config.MCPConfig
|
||||
mcpPermissionStore *internalmcp.PermissionStore
|
||||
mcpTokenStore *internalmcp.TokenStore
|
||||
mcpCommand func(context.Context, []string) MCPCommandResult
|
||||
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
mcpViewStateCache MCPViewState
|
||||
mcpViewStateReady bool
|
||||
mcpCommandSeq int
|
||||
mcpCommandCancel context.CancelFunc
|
||||
sandboxSetupSeq int
|
||||
sandboxSetupInFlight bool
|
||||
doctorCommandSeq int
|
||||
doctorInFlight bool
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
// titledSessions records session ids for which a model-generated title has
|
||||
// already been attempted this process, so a finished turn re-fires the title
|
||||
// generator at most once per session (even before its async result lands).
|
||||
@@ -100,35 +106,43 @@ type model struct {
|
||||
titledSessions map[string]bool
|
||||
// retitle* drive the sequential /retitle backfill: queued session ids still
|
||||
// awaiting a title, whether a backfill is running, and its progress counters.
|
||||
retitleQueue []string
|
||||
retitleActive bool
|
||||
retitleTotal int
|
||||
retitleDone int
|
||||
retitleOK int
|
||||
usageTracker *usage.Tracker
|
||||
sessionCompactor SessionCompactor
|
||||
prService *PrService
|
||||
prState PrState
|
||||
prWatcherStop func()
|
||||
runtimeMessageSink func(tea.Msg)
|
||||
agentOptions agent.Options
|
||||
notifier *notify.Notifier
|
||||
permissionMode agent.PermissionMode
|
||||
selfCorrectTests bool
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
responseStyle string
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
userAgent string
|
||||
compactRequests int
|
||||
compactInFlight bool
|
||||
compactFrame int
|
||||
lastCompactResult *CompactResult
|
||||
lastCompactError string
|
||||
unpricedRequests int
|
||||
unpricedTokens int
|
||||
lastUsage usage.Normalized
|
||||
lastUsageSeen bool
|
||||
retitleQueue []string
|
||||
retitleActive bool
|
||||
retitleTotal int
|
||||
retitleDone int
|
||||
retitleOK int
|
||||
usageTracker *usage.Tracker
|
||||
sessionCompactor SessionCompactor
|
||||
prService *PrService
|
||||
prState PrState
|
||||
prWatcherStop func()
|
||||
runtimeMessageSink func(tea.Msg)
|
||||
agentOptions agent.Options
|
||||
notifier *notify.Notifier
|
||||
permissionMode agent.PermissionMode
|
||||
selfCorrectTests bool
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
responseStyle string
|
||||
keyBindings keyBindings
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
userAgent string
|
||||
compactRequests int
|
||||
compactInFlight bool
|
||||
compactFrame int
|
||||
lastCompactResult *CompactResult
|
||||
lastCompactError string
|
||||
unpricedRequests int
|
||||
unpricedTokens int
|
||||
lastUsage usage.Normalized
|
||||
lastUsageSeen bool
|
||||
// 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
|
||||
@@ -252,13 +266,30 @@ type model struct {
|
||||
headerPrinted bool
|
||||
|
||||
// Composer input history (shell-style ↑/↓ recall of submitted inputs).
|
||||
// lastPrompt is the verbatim text of the most recent submitted prompt, so
|
||||
// /retry can resend it and /edit can recall it into the composer.
|
||||
lastPrompt string
|
||||
// lastImages/lastImageLabels/lastDocuments remember the attachments consumed
|
||||
// by the most recent submitted prompt. launchPrompt clears the pending queues
|
||||
// once a turn is sent, so /retry re-stages these to reproduce the exact same
|
||||
// request — otherwise a vision/PDF-backed prompt would silently retry as
|
||||
// text-only and answer a different task. They share the underlying image bytes
|
||||
// with the sent turn (never mutated in place), so no deep copy is needed.
|
||||
lastImages []zeroruntime.ImageBlock
|
||||
lastImageLabels []string
|
||||
lastDocuments []pendingDocument
|
||||
// historyIdx == len(inputHistory) means "not navigating"; historyDraft
|
||||
// preserves whatever was typed before recall started.
|
||||
inputHistory []string
|
||||
historyIdx int
|
||||
historyDraft string
|
||||
|
||||
streamingText string // live assistant text for the current segment
|
||||
// streamingText is the live assistant text for the current segment, accumulated
|
||||
// as []byte so each delta is an O(1) amortized append instead of the O(n²) that
|
||||
// string += delta incurs across a long generation. Read via streamingTextString().
|
||||
// A []byte (not strings.Builder) because the model is copied by value on every
|
||||
// Update, which would trip strings.Builder's copy check.
|
||||
streamingText []byte
|
||||
streamingReasoning string // live provider reasoning for the current segment
|
||||
streamingReasoningExpanded bool
|
||||
// turnStreamedRunes accumulates every reasoning+answer rune streamed in the
|
||||
@@ -451,6 +482,9 @@ type agentResponseMsg struct {
|
||||
// Turn metadata for settled rows that do not otherwise carry it.
|
||||
turnTools int
|
||||
turnElapsed time.Duration
|
||||
// ttft is time-to-first-token for the turn (0 when nothing streamed — a
|
||||
// tool-only or errored turn). Set only on the success path.
|
||||
ttft time.Duration
|
||||
}
|
||||
|
||||
type agentRowMsg struct {
|
||||
@@ -726,6 +760,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
keyBindings: resolveKeyBindings(options.KeyBindings),
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
@@ -755,6 +790,11 @@ func newModel(ctx context.Context, options Options) model {
|
||||
// Streaming text always renders statically at base ink (the disabled path in
|
||||
// styleStreamingLine), so no accent glow and no per-line fade ticks.
|
||||
m.fadeDisabled = true
|
||||
// One session-long LSP manager (cheap to build — servers start lazily on the
|
||||
// first Check), reused across prompts so gopls stays warm between turns.
|
||||
if cwd != "" {
|
||||
m.lspManager = lsp.NewManager(cwd)
|
||||
}
|
||||
m.refreshMCPViewState()
|
||||
return m
|
||||
}
|
||||
@@ -884,9 +924,22 @@ func (m model) noBlockingModal() bool {
|
||||
func (m model) quit() (tea.Model, tea.Cmd) {
|
||||
m.stopPRWatcher()
|
||||
m.stopAllBackgroundTerminalSessions()
|
||||
m.shutdownLSPManager()
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
// shutdownLSPManager gracefully stops the session-long language servers on exit.
|
||||
// Best-effort with a short deadline so a slow server can't hang the quit; the
|
||||
// servers are our child processes and would be reaped on exit regardless.
|
||||
func (m model) shutdownLSPManager() {
|
||||
if m.lspManager == nil {
|
||||
return
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = m.lspManager.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
func (m model) handleCtrlC() (tea.Model, tea.Cmd) {
|
||||
if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
|
||||
m.clearComposer()
|
||||
@@ -1078,7 +1131,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case keyCtrl(msg, 'c'):
|
||||
return m.handleCtrlC()
|
||||
case keyCtrl(msg, 'o'):
|
||||
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"):
|
||||
// Mode toggle for the file drill-in, only while the composer is empty
|
||||
@@ -1088,12 +1141,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.setFileViewMode(fileViewFull), nil
|
||||
}
|
||||
return m.setFileViewMode(fileViewDiff), nil
|
||||
case keyCtrl(msg, 'e'):
|
||||
case m.keyMatch(m.keyBindings.toggleMouse, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'e') }):
|
||||
// Release/recapture the mouse so the user can drag-select and copy text
|
||||
// natively (mouse capture otherwise intercepts terminal selection).
|
||||
m.mouseReleased = !m.mouseReleased
|
||||
if m.mouseReleased {
|
||||
return m.appendSystemNotice("Mouse released — drag to select and copy text. Press Ctrl+E again to re-enable mouse interaction (clicks, right-click paste)."), nil
|
||||
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
|
||||
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
|
||||
}
|
||||
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
@@ -1268,7 +1322,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.permissionMode = nextPermissionMode(m.permissionMode)
|
||||
return m, nil
|
||||
}
|
||||
case keyCtrl(msg, 't'):
|
||||
case m.keyMatch(m.keyBindings.cycleReasoning, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 't') }):
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
@@ -1281,20 +1335,20 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.noBlockingModal() {
|
||||
return m.cycleReasoningEffort()
|
||||
}
|
||||
case keyCtrl(msg, 'p'):
|
||||
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).
|
||||
if m.noBlockingModal() && !m.plan.isEmpty() {
|
||||
m.plan.expanded = !m.plan.expanded
|
||||
return m, nil
|
||||
}
|
||||
case keyCtrl(msg, 'b'):
|
||||
case m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }):
|
||||
// Ctrl+B collapses / restores the right context sidebar. Only acts when
|
||||
// the sidebar would otherwise be on screen (managed mode, wide enough,
|
||||
// real conversation) so it's a no-op — not a confusing notice — on the
|
||||
// home screen or a narrow terminal. Hiding reflows the chat to full
|
||||
// width, so mirror the width-change bookkeeping (re-wrap the streaming
|
||||
// fade, resize the composer) the WindowSizeMsg path does.
|
||||
if m.noBlockingModal() && m.sidebarAvailable() {
|
||||
if m.noBlockingModal() && m.sidebarToggleAllowed() {
|
||||
// Just show/hide — no transcript notice. The reflow IS the feedback,
|
||||
// and emitting a line every toggle piled up noise in the chat.
|
||||
m.sidebarHidden = !m.sidebarHidden
|
||||
@@ -1557,7 +1611,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Streaming text means any in-progress tool call has finished — clear the
|
||||
// live "writing" block so it doesn't linger over new prose.
|
||||
m.clearStreamingToolCall()
|
||||
m.streamingText += msg.delta
|
||||
m.streamingText = append(m.streamingText, msg.delta...)
|
||||
m.turnStreamedRunes += utf8.RuneCountInString(msg.delta)
|
||||
// recordStreamingDelta appends a time.Time to lineAges for every
|
||||
// newline in the delta and bumps lastStreamActivity. It also
|
||||
@@ -1840,22 +1894,35 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if row, ok := reasoningTranscriptRow("", msg.runID, m.streamingReasoning); ok {
|
||||
m.transcript = appendTranscriptRow(m.transcript, row)
|
||||
}
|
||||
if text := strings.TrimRight(m.streamingText, "\n"); strings.TrimSpace(text) != "" {
|
||||
if text := strings.TrimRight(m.streamingTextString(), "\n"); strings.TrimSpace(text) != "" {
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowAssistant, text: text})
|
||||
}
|
||||
// The error row terminates the turn, so it carries the done-line
|
||||
// metadata a final assistant row would have carried.
|
||||
// metadata a final assistant row would have carried. A recognized
|
||||
// provider failure (auth/rate-limit/connectivity/…) also carries a
|
||||
// one-line next step so the user isn't left staring at a raw blob.
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
|
||||
kind: rowError,
|
||||
text: msg.err.Error(),
|
||||
hint: errhint.TUIHint(msg.err),
|
||||
final: true,
|
||||
turnTools: msg.turnTools,
|
||||
turnElapsed: msg.turnElapsed,
|
||||
})
|
||||
}
|
||||
m.streamingText = ""
|
||||
m.streamingText = nil
|
||||
m.streamingReasoning = ""
|
||||
m.streamingReasoningExpanded = false
|
||||
// Roll the completed run's wall-time into the session's rolling average so
|
||||
// /context can surface typical turn latency, not just token counts.
|
||||
if msg.turnElapsed > 0 {
|
||||
m.turnLatencySum += msg.turnElapsed
|
||||
m.turnLatencyCount++
|
||||
}
|
||||
if msg.ttft > 0 {
|
||||
m.turnTTFTSum += msg.ttft
|
||||
m.turnTTFTCount++
|
||||
}
|
||||
if msg.specReview != nil {
|
||||
m = m.activateSpecReview(*msg.specReview)
|
||||
}
|
||||
@@ -2018,14 +2085,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.streamingReasoning = ""
|
||||
m.streamingReasoningExpanded = false
|
||||
}
|
||||
if text := strings.TrimRight(m.streamingText, "\n"); strings.TrimSpace(text) != "" {
|
||||
if text := strings.TrimRight(m.streamingTextString(), "\n"); strings.TrimSpace(text) != "" {
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowAssistant, text: text})
|
||||
// This interim narration is the agent explaining what it's about to
|
||||
// do — attribute it to the active plan step so the step-detail card
|
||||
// can replay the agent's own account of the work.
|
||||
m = m.captureStepNarration(text)
|
||||
}
|
||||
m.streamingText = ""
|
||||
m.streamingText = nil
|
||||
// The tool call has finalized into its card — drop the live "writing"
|
||||
// preview so it doesn't linger or duplicate beneath the card.
|
||||
m.clearStreamingToolCall()
|
||||
@@ -2360,6 +2427,10 @@ func (m model) composerIdleHint() string {
|
||||
m.subchat.active || m.suggestionsActive() || m.transcriptDetailed {
|
||||
return ""
|
||||
}
|
||||
sidebarKey := labelOr(m.keyBindings.toggleSidebar, "Ctrl+B")
|
||||
detailKey := labelOr(m.keyBindings.toggleDetailed, "Ctrl+O")
|
||||
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
|
||||
|
||||
var hint string
|
||||
switch widthTier(m.width) {
|
||||
case tierTiny:
|
||||
@@ -2367,9 +2438,9 @@ func (m model) composerIdleHint() string {
|
||||
case tierNarrow:
|
||||
hint = "? shortcuts"
|
||||
case tierMedium:
|
||||
hint = "? shortcuts · Ctrl+B sidebar · Ctrl+E copy"
|
||||
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s copy", sidebarKey, mouseKey)
|
||||
default:
|
||||
hint = "? shortcuts · Ctrl+B sidebar · Ctrl+O detail · Ctrl+E copy · Shift+Tab mode"
|
||||
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
|
||||
}
|
||||
return zeroTheme.faint.Render(hint)
|
||||
}
|
||||
@@ -2752,7 +2823,7 @@ func (m model) liveReasoningBodyCap() int {
|
||||
}
|
||||
|
||||
func (m model) interimBlock(width int) string {
|
||||
text := strings.TrimRight(m.streamingText, "\n")
|
||||
text := strings.TrimRight(m.streamingTextString(), "\n")
|
||||
reasoning := strings.TrimRight(m.streamingReasoning, "\n")
|
||||
blocks := []string{}
|
||||
if strings.TrimSpace(reasoning) != "" {
|
||||
@@ -2816,7 +2887,7 @@ func (m model) spinnerGlyph() string {
|
||||
// (reasoning, waiting on the model, or a tool in flight). Cheap and robust — no
|
||||
// transcript scan — so it can't misreport on a long, output-less step.
|
||||
func (m model) workingActivity() string {
|
||||
if strings.TrimSpace(m.streamingText) != "" {
|
||||
if strings.TrimSpace(m.streamingTextString()) != "" {
|
||||
return "writing"
|
||||
}
|
||||
return "thinking"
|
||||
@@ -3615,10 +3686,23 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case commandClear:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
// Clearing wipes the visible transcript only — the session's context is
|
||||
// intact, so the next prompt still replays the full history. Say so, and
|
||||
// point to /new, so "cleared screen" isn't mistaken for "fresh context."
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Transcript cleared. The agent still has the full session context — use /new to start a fresh session."})
|
||||
// Scrollback above can't be un-printed; a faint divider marks where the
|
||||
// cleared surface ended and the frontier restarts for the fresh transcript.
|
||||
m.resetFlushFrontier("· cleared ·")
|
||||
return m, nil
|
||||
case commandNew:
|
||||
// A fresh session mid-run would strand the in-flight turn's events; make the
|
||||
// user cancel first. Idle, /new saves the current session (already on disk)
|
||||
// and clears the conversation in place.
|
||||
if m.pending || m.compactInFlight {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "A run is in progress. Press Esc to cancel it first, then /new."})
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandExit:
|
||||
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
|
||||
// defer the quit until its checkpoint session events flush — quitting
|
||||
@@ -3851,6 +3935,62 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "$ " + cmdText})
|
||||
return m, runBashEscape(m.cwd, cmdText)
|
||||
case commandRetry:
|
||||
// /retry launches a run, so it needs the same guards a normal prompt gets:
|
||||
// never start one while exiting (would strand the shutdown flush) or during
|
||||
// compaction (would race compactResultMsg's wholesale rewrite of
|
||||
// transcript/sessionEvents and silently drop events).
|
||||
if m.exiting {
|
||||
return m, nil
|
||||
}
|
||||
if m.pending {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Retry\ncannot retry while a run is in progress."})
|
||||
return m, nil
|
||||
}
|
||||
if m.compactInFlight {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "Retry\nstatus: warning\nCompaction is running. Retry once it finishes.",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
if strings.TrimSpace(m.lastPrompt) == "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Retry\nno previous prompt to resend."})
|
||||
return m, nil
|
||||
}
|
||||
// Re-stage the attachments the last prompt carried so launchPrompt rebuilds
|
||||
// an identical request (document preamble + images + vision re-check). Without
|
||||
// this the queues are empty and /retry would resend a text-only prompt,
|
||||
// silently dropping the image/PDF context and answering a different task.
|
||||
m.pendingImages = m.lastImages
|
||||
m.pendingImageLabels = m.lastImageLabels
|
||||
m.pendingDocuments = m.lastDocuments
|
||||
return m.launchPrompt(m.lastPrompt)
|
||||
case commandEdit:
|
||||
if strings.TrimSpace(m.lastPrompt) == "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Edit\nno previous prompt to recall."})
|
||||
return m, nil
|
||||
}
|
||||
// Re-stage the remembered attachments alongside the recalled text so an
|
||||
// edited resend carries the same image/PDF context — the reappearing chip
|
||||
// row is the visible confirmation. Without this, editing a vision- or
|
||||
// document-backed prompt would silently submit a text-only version and
|
||||
// answer a different task (the same gap /retry guards against).
|
||||
m.pendingImages = m.lastImages
|
||||
m.pendingImageLabels = m.lastImageLabels
|
||||
m.pendingDocuments = m.lastDocuments
|
||||
m.input.SetValue(m.lastPrompt)
|
||||
return m, nil
|
||||
case commandCopy:
|
||||
text := m.lastAssistantAnswer()
|
||||
if strings.TrimSpace(text) == "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Copy\nno answer to copy yet."})
|
||||
return m, nil
|
||||
}
|
||||
return m, copyTranscriptSelectionCmd(text)
|
||||
case commandExport:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.handleExportCommand(command.text)})
|
||||
return m, nil
|
||||
case commandPrompt:
|
||||
if intent, ok := detectMCPSetupIntent(command.text); ok {
|
||||
return m.openMCPAddWizardFromIntent(intent), nil
|
||||
@@ -3865,6 +4005,15 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
// composer. Queued prompts use this path too, so session and image behavior
|
||||
// stays identical to immediate submissions.
|
||||
func (m model) launchPrompt(prompt string) (model, tea.Cmd) {
|
||||
// Remember the verbatim prompt (before specialist/document expansion) so /retry
|
||||
// and /edit can act on exactly what the user submitted. Snapshot the staged
|
||||
// attachments too: launchPrompt clears the pending queues below, so /retry
|
||||
// re-stages these to resend an identical vision/PDF-backed request rather than
|
||||
// a degraded text-only one.
|
||||
m.lastPrompt = prompt
|
||||
m.lastImages = m.pendingImages
|
||||
m.lastImageLabels = m.pendingImageLabels
|
||||
m.lastDocuments = m.pendingDocuments
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: prompt})
|
||||
if m.provider == nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
@@ -4052,7 +4201,7 @@ func (m *model) cancelRun() {
|
||||
if row, ok := reasoningTranscriptRow("", m.activeRunID, m.streamingReasoning); ok {
|
||||
m.transcript = appendTranscriptRow(m.transcript, row)
|
||||
}
|
||||
if text := strings.TrimRight(m.streamingText, "\n"); strings.TrimSpace(text) != "" {
|
||||
if text := strings.TrimRight(m.streamingTextString(), "\n"); strings.TrimSpace(text) != "" {
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowAssistant, text: text})
|
||||
}
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Run cancelled."})
|
||||
@@ -4073,7 +4222,7 @@ func (m *model) cancelRun() {
|
||||
m.pendingAskUser = nil
|
||||
// The interim block renders streamingText live; a cancelled run's partial
|
||||
// answer must not leak into (and concatenate with) the next turn's stream.
|
||||
m.streamingText = ""
|
||||
m.streamingText = nil
|
||||
m.streamingReasoning = ""
|
||||
m.streamingReasoningExpanded = false
|
||||
// Hard-stop the fade and drop the per-line age map. The next turn's
|
||||
@@ -4103,6 +4252,9 @@ func selfCorrectAutonomyForMode(mode agent.PermissionMode) string {
|
||||
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
started := m.now()
|
||||
// firstTokenAt is stamped when the first token (reasoning or text) streams,
|
||||
// so the turn can report time-to-first-token alongside total wall time.
|
||||
var firstTokenAt time.Time
|
||||
toolCalls := 0
|
||||
rows := []transcriptRow{}
|
||||
usageEvents := []zeroruntime.Usage{}
|
||||
@@ -4147,12 +4299,18 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
// matching exec; the per-turn lsp.Manager is torn down when this run
|
||||
// returns; auto-fix vs report-only follows the active permission mode.
|
||||
if !runOptions.specDraft && options.Cwd != "" {
|
||||
lspManager := lsp.NewManager(options.Cwd)
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = lspManager.Shutdown(shutdownCtx)
|
||||
}()
|
||||
// Prefer the session-long manager (kept warm across prompts). Only when it
|
||||
// is absent — e.g. cwd was unknown at construction, or a test built the
|
||||
// model directly — fall back to a per-run manager that is shut down here.
|
||||
lspManager := m.lspManager
|
||||
if lspManager == nil {
|
||||
lspManager = lsp.NewManager(options.Cwd)
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = lspManager.Shutdown(shutdownCtx)
|
||||
}()
|
||||
}
|
||||
options.SelfCorrect = agent.NewSelfCorrector(options.Cwd, agent.NewLSPDiagnosticsChecker(lspManager), agent.NewProjectVerifier(options.Cwd), agent.SelfCorrectConfig{
|
||||
Enabled: true,
|
||||
IncludeTests: m.selfCorrectTests,
|
||||
@@ -4195,6 +4353,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
|
||||
onText := options.OnText
|
||||
options.OnText = func(delta string) {
|
||||
if firstTokenAt.IsZero() {
|
||||
firstTokenAt = m.now()
|
||||
}
|
||||
if strings.TrimSpace(reasoningText) != "" {
|
||||
flushReasoning(m.now())
|
||||
}
|
||||
@@ -4290,6 +4451,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
onReasoning := options.OnReasoning
|
||||
options.OnReasoning = func(delta string) {
|
||||
now := m.now()
|
||||
if firstTokenAt.IsZero() && strings.TrimSpace(delta) != "" {
|
||||
firstTokenAt = now
|
||||
}
|
||||
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
|
||||
reasoningStarted = now
|
||||
}
|
||||
@@ -4526,6 +4690,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
}
|
||||
flushReasoning(m.now())
|
||||
elapsed := m.now().Sub(started)
|
||||
ttft := time.Duration(0)
|
||||
if !firstTokenAt.IsZero() {
|
||||
ttft = firstTokenAt.Sub(started)
|
||||
}
|
||||
rows = append(rows, transcriptRow{
|
||||
kind: rowAssistant,
|
||||
text: result.FinalAnswer,
|
||||
@@ -4543,7 +4711,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
"content": result.FinalAnswer,
|
||||
},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4600,6 +4768,13 @@ func (m model) sendAgentText(runID int, delta string) {
|
||||
m.runtimeMessageSink(agentTextMsg{runID: runID, delta: delta})
|
||||
}
|
||||
|
||||
// streamingTextString returns the accumulated live assistant text. streamingText
|
||||
// is stored as []byte for O(1) amortized appends; the conversion here is bounded
|
||||
// by the segment length, the same cost the renderer already pays.
|
||||
func (m model) streamingTextString() string {
|
||||
return string(m.streamingText)
|
||||
}
|
||||
|
||||
func (m model) sendToolCallStreamStart(runID int, id, name string) {
|
||||
if m.runtimeMessageSink == nil {
|
||||
return
|
||||
|
||||
@@ -340,8 +340,11 @@ func TestClearCommandResetsTranscript(t *testing.T) {
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected clear to reset transcript, got %#v", next.transcript)
|
||||
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected clear to reset transcript to welcome + note, got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "/new") {
|
||||
t.Fatalf("expected clear to point users to /new, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2298,7 +2301,7 @@ func TestStreamingAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
|
||||
m.width, m.height = 120, 40
|
||||
m.altScreen = true
|
||||
m.pending = true
|
||||
m.streamingText = "Done."
|
||||
m.streamingText = []byte("Done.")
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowUser, text: "run it"},
|
||||
transcriptRow{kind: rowAssistant, text: "I'll run it first."},
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestStartNewSessionResetsState(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
|
||||
m.sessionEvents = []sessions.Event{{Type: sessions.EventMessage}}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
|
||||
// Stage attachments + a queued message that /new must not leak into the new session.
|
||||
m.pendingImages = make([]zeroruntime.ImageBlock, 1)
|
||||
m.pendingImageLabels = []string{"pic.png"}
|
||||
m.pendingDocuments = []pendingDocument{{label: "doc.pdf"}}
|
||||
m.queuedMessage = "queued"
|
||||
// The /retry attachment snapshot is prior-session state too and must not survive.
|
||||
m.lastImages = make([]zeroruntime.ImageBlock, 1)
|
||||
m.lastImageLabels = []string{"pic.png"}
|
||||
m.lastDocuments = []pendingDocument{{label: "doc.pdf"}}
|
||||
|
||||
next := m.startNewSession()
|
||||
|
||||
if next.activeSession.SessionID != "" {
|
||||
t.Fatalf("expected active session id cleared, got %q", next.activeSession.SessionID)
|
||||
}
|
||||
if len(next.sessionEvents) != 0 {
|
||||
t.Fatalf("expected session events cleared, got %d", len(next.sessionEvents))
|
||||
}
|
||||
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected transcript reset to welcome + note, got %#v", next.transcript)
|
||||
}
|
||||
// The note must name the prior session id so the user can /resume it.
|
||||
if !transcriptContains(next.transcript, "sess-old") {
|
||||
t.Fatalf("expected note to reference previous session id, got %#v", next.transcript)
|
||||
}
|
||||
// Staged attachments and the queued message must not leak into the new session.
|
||||
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 || len(next.pendingDocuments) != 0 || next.queuedMessage != "" {
|
||||
t.Fatalf("startNewSession must clear staged input, got images=%d labels=%d docs=%d queued=%q",
|
||||
len(next.pendingImages), len(next.pendingImageLabels), len(next.pendingDocuments), next.queuedMessage)
|
||||
}
|
||||
// The /retry snapshot must not leak the previous session's attachments.
|
||||
if len(next.lastImages) != 0 || len(next.lastImageLabels) != 0 || len(next.lastDocuments) != 0 {
|
||||
t.Fatalf("startNewSession must clear the retry snapshot, got images=%d labels=%d docs=%d",
|
||||
len(next.lastImages), len(next.lastImageLabels), len(next.lastDocuments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandStartsFreshSession(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
|
||||
m.input.SetValue("/new")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if next.activeSession.SessionID != "" {
|
||||
t.Fatalf("expected /new to clear the active session, got %q", next.activeSession.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandDoesNotResetDuringRun(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
|
||||
m.pending = true
|
||||
m.input.SetValue("/new")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
// The safety invariant: /new must never strand an in-flight session.
|
||||
if next.activeSession.SessionID != "sess-old" {
|
||||
t.Fatalf("/new must not reset an in-flight session, got %q", next.activeSession.SessionID)
|
||||
}
|
||||
}
|
||||
@@ -1151,6 +1151,29 @@ func (m model) setupProvider() SetupProviderOption {
|
||||
return m.setup.providers[index]
|
||||
}
|
||||
|
||||
// setupErrorAffordance returns a faint one-line recovery hint tailored to the
|
||||
// current setup stage, so a first-run error points the user at the inline fix
|
||||
// instead of leaving them staring at a red line. Empty for stages whose footer
|
||||
// already makes recovery obvious. Each hint names only keys the stage's footer
|
||||
// confirms, so the guidance is always accurate.
|
||||
func (m model) setupErrorAffordance() string {
|
||||
switch m.setup.stage {
|
||||
case setupStageEndpoint:
|
||||
return "→ edit the endpoint above, then Enter to retry (← to go back)"
|
||||
case setupStageCredentials:
|
||||
if m.setupCredentialInputActive() {
|
||||
return "→ re-enter the key above, then Enter to retry (← to go back)"
|
||||
}
|
||||
case setupStageName:
|
||||
return "→ edit the name above, then Enter to continue (← to go back)"
|
||||
case setupStageModel:
|
||||
if !m.setup.modelLoad {
|
||||
return "→ pick another model with ↑/↓, or type to search"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m model) setupView(width int) string {
|
||||
if width <= 0 {
|
||||
width = defaultStartupWidth
|
||||
@@ -1159,6 +1182,9 @@ func (m model) setupView(width int) string {
|
||||
content := m.setupStageLines(width, height)
|
||||
if m.setup.err != "" {
|
||||
content = append(content, "", zeroTheme.red.Render("error: "+m.setup.err))
|
||||
if hint := m.setupErrorAffordance(); hint != "" {
|
||||
content = append(content, zeroTheme.faint.Render(hint))
|
||||
}
|
||||
}
|
||||
progress := m.setupProgressText()
|
||||
footer := m.setupFooter()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Each input/choice stage's error affordance names an accurate recovery step;
|
||||
// stages without a tailored hint return empty.
|
||||
func TestSetupErrorAffordance(t *testing.T) {
|
||||
endpoint := model{}
|
||||
endpoint.setup.stage = setupStageEndpoint
|
||||
if got := endpoint.setupErrorAffordance(); !strings.Contains(got, "endpoint") || !strings.Contains(got, "Enter") {
|
||||
t.Fatalf("endpoint affordance = %q", got)
|
||||
}
|
||||
|
||||
name := model{}
|
||||
name.setup.stage = setupStageName
|
||||
if got := name.setupErrorAffordance(); !strings.Contains(got, "name") {
|
||||
t.Fatalf("name affordance = %q", got)
|
||||
}
|
||||
|
||||
// A stage with no tailored hint (method chooser) returns empty.
|
||||
method := model{}
|
||||
method.setup.stage = setupStageMethod
|
||||
if got := method.setupErrorAffordance(); got != "" {
|
||||
t.Fatalf("method stage should have no affordance, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,10 @@ type Options struct {
|
||||
// Notify configures completion / awaiting-input notifications.
|
||||
Notify config.NotifyConfig
|
||||
|
||||
// KeyBindings configures remappable TUI keybindings. An empty/zero
|
||||
// KeyBindingsConfig means "use built-in defaults" for each action.
|
||||
KeyBindings config.KeyBindingsConfig
|
||||
|
||||
// AltScreen tells the model it is running inside Bubble Tea's alternate
|
||||
// screen. Run sets this for the interactive app; tests can leave it false
|
||||
// to exercise the native scrollback renderer.
|
||||
|
||||
+80
-5
@@ -102,16 +102,83 @@ func (p *commandPicker) applyQuery() {
|
||||
p.selected = clampInt(p.selected, 0, maxInt(0, len(p.items)-1))
|
||||
return
|
||||
}
|
||||
filtered := make([]pickerItem, 0, len(source))
|
||||
for _, item := range source {
|
||||
if strings.Contains(strings.ToLower(strings.Join([]string{item.Group, item.Label, item.Value, item.Meta}, " ")), query) {
|
||||
filtered = append(filtered, item)
|
||||
|
||||
// Rank matches (exact < prefix < contains < subsequence) instead of a flat
|
||||
// substring filter, so the closest match to "sonnet 4.5" lands at the top
|
||||
// rather than buried behind scrolling. Groups stay contiguous — a group is
|
||||
// ordered by its best-matching item and never split into two header blocks —
|
||||
// so the grouped model picker still renders one header per provider.
|
||||
type entry struct {
|
||||
item pickerItem
|
||||
score int
|
||||
order int
|
||||
}
|
||||
groupFirst := map[string]int{}
|
||||
groupBest := map[string]int{}
|
||||
entries := make([]entry, 0, len(source))
|
||||
for index, item := range source {
|
||||
score, ok := scorePickerItem(item, query)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, seen := groupFirst[item.Group]; !seen {
|
||||
groupFirst[item.Group] = index
|
||||
}
|
||||
if best, seen := groupBest[item.Group]; !seen || score < best {
|
||||
groupBest[item.Group] = score
|
||||
}
|
||||
entries = append(entries, entry{item: item, score: score, order: index})
|
||||
}
|
||||
sort.SliceStable(entries, func(a, b int) bool {
|
||||
ga, gb := entries[a].item.Group, entries[b].item.Group
|
||||
if ga != gb {
|
||||
// Most-relevant group first; ties keep original group appearance order so
|
||||
// a group is never scattered across the list.
|
||||
if groupBest[ga] != groupBest[gb] {
|
||||
return groupBest[ga] < groupBest[gb]
|
||||
}
|
||||
return groupFirst[ga] < groupFirst[gb]
|
||||
}
|
||||
// Within a group: best match first, then original order.
|
||||
if entries[a].score != entries[b].score {
|
||||
return entries[a].score < entries[b].score
|
||||
}
|
||||
return entries[a].order < entries[b].order
|
||||
})
|
||||
filtered := make([]pickerItem, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
filtered = append(filtered, e.item)
|
||||
}
|
||||
p.items = filtered
|
||||
p.selected = 0
|
||||
}
|
||||
|
||||
// scorePickerItem ranks an item against a lowercased query; lower is better, and
|
||||
// ok is false when it doesn't match at all. Tiers mirror scoreFileSuggestion:
|
||||
// exact/prefix/contains on the label (what the user reads) beat matches deeper in
|
||||
// the joined haystack, and a fuzzy subsequence is the last-resort match.
|
||||
func scorePickerItem(item pickerItem, query string) (int, bool) {
|
||||
label := strings.ToLower(item.Label)
|
||||
hay := strings.ToLower(strings.Join([]string{item.Group, item.Label, item.Value, item.Meta}, " "))
|
||||
switch {
|
||||
case label == query:
|
||||
return 0, true
|
||||
case strings.HasPrefix(label, query):
|
||||
return 20, true
|
||||
case strings.Contains(label, query):
|
||||
return 40, true
|
||||
case strings.HasPrefix(hay, query):
|
||||
return 60, true
|
||||
case strings.Contains(hay, query):
|
||||
return 80, true
|
||||
default:
|
||||
if gap, ok := fuzzySubsequenceGap(hay, query); ok {
|
||||
return 120 + gap, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// newModelPicker lists active (non-deprecated) models, preselecting the active
|
||||
// one. Returns nil when the catalog is unavailable so the caller falls back to
|
||||
// the plain status text.
|
||||
@@ -619,7 +686,15 @@ func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor)
|
||||
strings.TrimSpace(profile.Name) == "" ||
|
||||
strings.TrimSpace(profile.CatalogID) == ""
|
||||
if normalizeIdentity {
|
||||
profile.Name = provider.ID
|
||||
// Only canonicalize an empty or generic placeholder name — never clobber a
|
||||
// real user-provided profile name. The credential store is keyed by
|
||||
// profile.Name (SecureProviderProfile stores under Name), so rewriting it
|
||||
// here made the later profileWithCredential lookup miss the saved key and
|
||||
// rebuild a keyless provider — a 401 on every /model switch for any profile
|
||||
// named differently from its catalog id (e.g. "my-openai").
|
||||
if strings.TrimSpace(profile.Name) == "" || genericProviderCatalogID(profile.Name) {
|
||||
profile.Name = provider.ID
|
||||
}
|
||||
profile.CatalogID = provider.ID
|
||||
}
|
||||
if strings.TrimSpace(profile.BaseURL) == "" {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newFuzzyTestPicker(items []pickerItem) *commandPicker {
|
||||
return &commandPicker{
|
||||
kind: pickerModel,
|
||||
items: append([]pickerItem{}, items...),
|
||||
allItems: append([]pickerItem{}, items...),
|
||||
}
|
||||
}
|
||||
|
||||
// A closer match ranks above a merely-containing one, so the best result lands at
|
||||
// the top instead of in original order.
|
||||
func TestPickerRanksBestMatchFirst(t *testing.T) {
|
||||
p := newFuzzyTestPicker([]pickerItem{
|
||||
{Label: "Claude Opus 4.5", Value: "opus-4.5"},
|
||||
{Label: "Claude Sonnet 4.5", Value: "sonnet-4.5"},
|
||||
{Label: "Sonnet", Value: "sonnet"}, // exact-ish prefix match should win
|
||||
})
|
||||
p.query = "sonnet"
|
||||
p.applyQuery()
|
||||
|
||||
if len(p.items) != 2 {
|
||||
t.Fatalf("expected 2 matches for 'sonnet', got %d: %#v", len(p.items), p.items)
|
||||
}
|
||||
if p.items[0].Value != "sonnet" {
|
||||
t.Fatalf("exact/prefix match should rank first, got %q", p.items[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
// A subsequence query (non-contiguous characters) still matches when no substring does.
|
||||
func TestPickerFuzzySubsequenceMatches(t *testing.T) {
|
||||
p := newFuzzyTestPicker([]pickerItem{
|
||||
{Label: "Claude Sonnet 4.5", Value: "sonnet-4.5"},
|
||||
{Label: "GPT-5", Value: "gpt-5"},
|
||||
})
|
||||
p.query = "snt45" // subsequence of "sonnet 4.5", not a substring
|
||||
p.applyQuery()
|
||||
|
||||
if len(p.items) != 1 || p.items[0].Value != "sonnet-4.5" {
|
||||
t.Fatalf("subsequence query should match sonnet-4.5, got %#v", p.items)
|
||||
}
|
||||
}
|
||||
|
||||
// Ranking keeps each group contiguous: a filtered result never splits one group
|
||||
// into two separate blocks, so group headers still render once per group.
|
||||
func TestPickerRankingKeepsGroupsContiguous(t *testing.T) {
|
||||
p := newFuzzyTestPicker([]pickerItem{
|
||||
{Group: "openai", Label: "gpt-5 mini", Value: "a"},
|
||||
{Group: "anthropic", Label: "sonnet 4.5", Value: "b"},
|
||||
{Group: "openai", Label: "gpt-5 codex", Value: "c"},
|
||||
{Group: "anthropic", Label: "opus 4.5", Value: "d"},
|
||||
})
|
||||
p.query = "5" // matches both openai models and the "4.5" anthropic ones
|
||||
p.applyQuery()
|
||||
|
||||
// Every item's group must appear as one contiguous run.
|
||||
seen := map[string]bool{}
|
||||
last := ""
|
||||
for _, item := range p.items {
|
||||
if item.Group != last {
|
||||
if seen[item.Group] {
|
||||
t.Fatalf("group %q split into non-contiguous blocks: %#v", item.Group, p.items)
|
||||
}
|
||||
seen[item.Group] = true
|
||||
last = item.Group
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -976,3 +976,49 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
|
||||
t.Fatal("switching to ollama should warm both the generic and ollama-specific discovery commands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProfileForProviderPreservesCustomName(t *testing.T) {
|
||||
openai, ok := providercatalog.Get("openai")
|
||||
if !ok {
|
||||
t.Fatal("openai descriptor missing from catalog")
|
||||
}
|
||||
|
||||
// A first-party provider set up with a custom profile name and the baseURL at
|
||||
// the catalog default. normalizeIdentity is true here (baseURL matches), but
|
||||
// the real name must survive: it is the credential-store key, so clobbering it
|
||||
// dropped the saved key and 401'd every /model switch (issue #440).
|
||||
m := model{providerProfile: config.ProviderProfile{
|
||||
Name: "my-openai",
|
||||
CatalogID: "openai",
|
||||
BaseURL: openai.DefaultBaseURL,
|
||||
APIKeyStored: true,
|
||||
}}
|
||||
got := m.normalizeProfileForProvider(openai)
|
||||
if got.Name != "my-openai" {
|
||||
t.Fatalf("Name = %q, want it preserved as %q (credential store is keyed by Name)", got.Name, "my-openai")
|
||||
}
|
||||
if got.CatalogID != "openai" {
|
||||
t.Fatalf("CatalogID = %q, want it canonicalized to %q", got.CatalogID, "openai")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProfileForProviderCanonicalizesPlaceholderName(t *testing.T) {
|
||||
openai, ok := providercatalog.Get("openai")
|
||||
if !ok {
|
||||
t.Fatal("openai descriptor missing from catalog")
|
||||
}
|
||||
|
||||
// Empty and generic placeholder names still get canonicalized to the catalog
|
||||
// id — those carry no meaningful credential-store key to preserve.
|
||||
for _, name := range []string{"", "custom-openai-compatible"} {
|
||||
m := model{providerProfile: config.ProviderProfile{
|
||||
Name: name,
|
||||
BaseURL: openai.DefaultBaseURL,
|
||||
CatalogID: name,
|
||||
}}
|
||||
got := m.normalizeProfileForProvider(openai)
|
||||
if got.Name != "openai" {
|
||||
t.Fatalf("Name = %q for placeholder %q, want canonicalized to %q", got.Name, name, "openai")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,7 @@ type providerWizardState struct {
|
||||
selectedProvider int
|
||||
models []providerWizardModel
|
||||
selectedModel int
|
||||
providerSearch string
|
||||
modelSearch string
|
||||
baseURL string
|
||||
profileName string
|
||||
@@ -343,7 +344,18 @@ func providerWizardProviders() []providercatalog.Descriptor {
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) currentProvider() providercatalog.Descriptor {
|
||||
if wizard == nil || len(wizard.providers) == 0 {
|
||||
if wizard == nil {
|
||||
return providercatalog.Descriptor{}
|
||||
}
|
||||
if wizard.step == providerWizardStepProvider {
|
||||
providers := wizard.filteredProviders()
|
||||
if len(providers) == 0 {
|
||||
return providercatalog.Descriptor{}
|
||||
}
|
||||
wizard.selectedProvider = clampInt(wizard.selectedProvider, 0, len(providers)-1)
|
||||
return providers[wizard.selectedProvider]
|
||||
}
|
||||
if len(wizard.providers) == 0 {
|
||||
return providercatalog.Descriptor{}
|
||||
}
|
||||
wizard.selectedProvider = clampInt(wizard.selectedProvider, 0, len(wizard.providers)-1)
|
||||
@@ -399,10 +411,11 @@ func (wizard *providerWizardState) move(delta int) {
|
||||
}
|
||||
wizard.selectedMethod = ((wizard.selectedMethod+delta)%len(options) + len(options)) % len(options)
|
||||
case providerWizardStepProvider:
|
||||
if len(wizard.providers) == 0 {
|
||||
providers := wizard.filteredProviders()
|
||||
if len(providers) == 0 {
|
||||
return
|
||||
}
|
||||
wizard.selectedProvider = ((wizard.selectedProvider+delta)%len(wizard.providers) + len(wizard.providers)) % len(wizard.providers)
|
||||
wizard.selectedProvider = ((wizard.selectedProvider+delta)%len(providers) + len(providers)) % len(providers)
|
||||
wizard.selectedModel = 0
|
||||
wizard.modelSearch = ""
|
||||
wizard.baseURL = ""
|
||||
@@ -450,6 +463,21 @@ func (wizard *providerWizardState) advance() {
|
||||
if wizard.oauthMode {
|
||||
return
|
||||
}
|
||||
if len(wizard.filteredProviders()) == 0 {
|
||||
return
|
||||
}
|
||||
// Resolve the selected provider's index in the full list before clearing
|
||||
// the search — selectedProvider is an index into the filtered slice, and
|
||||
// clearing the search swaps to the full list.
|
||||
if selected := wizard.currentProvider(); selected.ID != "" {
|
||||
for i, p := range wizard.providers {
|
||||
if p.ID == selected.ID {
|
||||
wizard.selectedProvider = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
wizard.providerSearch = ""
|
||||
wizard.refreshModels()
|
||||
wizard.err = ""
|
||||
if providerWizardNeedsEndpoint(wizard.currentProvider()) {
|
||||
@@ -513,6 +541,7 @@ func (wizard *providerWizardState) retreat() {
|
||||
wizard.step = providerWizardStepMethod
|
||||
case providerWizardStepEndpoint:
|
||||
wizard.step = providerWizardStepProvider
|
||||
wizard.providerSearch = ""
|
||||
case providerWizardStepName:
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
case providerWizardStepCredential:
|
||||
@@ -628,6 +657,20 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
m.providerWizard.currentProvider().OAuthDeviceFlow {
|
||||
return m.startProviderDeviceLogin()
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepProvider {
|
||||
switch {
|
||||
case keyText(msg) != "":
|
||||
m.providerWizard.appendProviderSearch(keyRunes(msg))
|
||||
return m, nil
|
||||
case keyBackspace(msg):
|
||||
m.providerWizard.deleteProviderSearchRune()
|
||||
return m, nil
|
||||
case keyCtrl(msg, 'u'):
|
||||
m.providerWizard.providerSearch = ""
|
||||
m.providerWizard.selectedProvider = 0
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepEndpoint {
|
||||
switch {
|
||||
case keyText(msg) != "":
|
||||
@@ -746,6 +789,8 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
switch m.providerWizard.step {
|
||||
case providerWizardStepProvider:
|
||||
m.providerWizard.appendProviderSearch([]rune(content))
|
||||
case providerWizardStepEndpoint:
|
||||
m.providerWizard.appendBaseURL([]rune(content))
|
||||
case providerWizardStepName:
|
||||
@@ -879,6 +924,50 @@ func (wizard *providerWizardState) deleteModelSearchRune() {
|
||||
wizard.selectedModel = 0
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) appendProviderSearch(runes []rune) {
|
||||
for _, r := range runes {
|
||||
if unicode.IsControl(r) {
|
||||
continue
|
||||
}
|
||||
wizard.providerSearch += string(r)
|
||||
}
|
||||
wizard.selectedProvider = 0
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) deleteProviderSearchRune() {
|
||||
if wizard.providerSearch == "" {
|
||||
return
|
||||
}
|
||||
runes := []rune(wizard.providerSearch)
|
||||
wizard.providerSearch = string(runes[:len(runes)-1])
|
||||
wizard.selectedProvider = 0
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) filteredProviders() []providercatalog.Descriptor {
|
||||
if wizard == nil {
|
||||
return nil
|
||||
}
|
||||
query := strings.ToLower(strings.TrimSpace(wizard.providerSearch))
|
||||
if query == "" {
|
||||
return append([]providercatalog.Descriptor{}, wizard.providers...)
|
||||
}
|
||||
providers := make([]providercatalog.Descriptor, 0, len(wizard.providers))
|
||||
for _, provider := range wizard.providers {
|
||||
if providerMatchesQuery(provider, query) {
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func providerMatchesQuery(provider providercatalog.Descriptor, query string) bool {
|
||||
if query == "" {
|
||||
return true
|
||||
}
|
||||
haystack := strings.ToLower(strings.Join([]string{provider.ID, provider.Name, strings.Join(provider.Aliases, " ")}, " "))
|
||||
return strings.Contains(haystack, query)
|
||||
}
|
||||
|
||||
func (m model) applyProviderWizard() (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil {
|
||||
@@ -1248,9 +1337,15 @@ func (wizard *providerWizardState) renderProviderStep(width int) []string {
|
||||
header = "Choose an OAuth provider"
|
||||
}
|
||||
lines := []string{zeroTheme.accent.Render(header)}
|
||||
maxVisible := minInt(maxProviderWizardProvidersVisible, len(wizard.providers))
|
||||
start := selectableListStart(len(wizard.providers), maxVisible, wizard.selectedProvider)
|
||||
for offset, provider := range wizard.providers[start : start+maxVisible] {
|
||||
lines = append(lines, wizard.renderProviderSearch(width))
|
||||
providers := wizard.filteredProviders()
|
||||
if len(providers) == 0 {
|
||||
lines = append(lines, zeroTheme.faint.Render(" no matching providers"))
|
||||
return lines
|
||||
}
|
||||
maxVisible := minInt(maxProviderWizardProvidersVisible, len(providers))
|
||||
start := selectableListStart(len(providers), maxVisible, wizard.selectedProvider)
|
||||
for offset, provider := range providers[start : start+maxVisible] {
|
||||
lines = append(lines, wizard.renderSelectableProvider(width, start+offset, provider))
|
||||
}
|
||||
// A failed OAuth attempt leaves the wizard on this list (it does not advance),
|
||||
@@ -1265,6 +1360,11 @@ func (wizard *providerWizardState) renderProviderStep(width int) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) renderProviderSearch(width int) string {
|
||||
query := strings.TrimSpace(wizard.providerSearch)
|
||||
return providerWizardInputLine("search > ", query, "provider name, id, or alias...", width)
|
||||
}
|
||||
|
||||
// providerWizardOAuthErrHint returns a provider-specific next step for a failed
|
||||
// OAuth login. Hugging Face ships no public client_id (unlike xAI), so a login
|
||||
// can only work once the operator registers an app and supplies its client_id.
|
||||
@@ -1602,7 +1702,7 @@ func providerWizardEndpointError(value string) string {
|
||||
return "endpoint URL must start with http:// or https://"
|
||||
}
|
||||
if parsed.Scheme == "http" && !providerWizardIsLoopbackHost(parsed.Hostname()) {
|
||||
return "endpoint URL must use https:// unless it is local loopback"
|
||||
return "endpoint URL must use https:// unless it is local loopback or a private network address"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1613,7 +1713,7 @@ func providerWizardIsLoopbackHost(host string) bool {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
|
||||
}
|
||||
|
||||
func providerWizardDisplayName(provider providercatalog.Descriptor, baseURL string, profileName string) string {
|
||||
|
||||
@@ -1355,3 +1355,139 @@ func TestApplyProviderWizardPersistFailureLeavesLiveStateUnchanged(t *testing.T)
|
||||
t.Fatalf("%s = %q, want it unchanged on persist failure (children would diverge from parent)", config.ActiveProviderEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSearchFiltersByNameIDAndAlias(t *testing.T) {
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepProvider,
|
||||
providers: []providercatalog.Descriptor{
|
||||
{ID: "openai", Name: "OpenAI", Aliases: []string{"chatgpt"}},
|
||||
{ID: "anthropic", Name: "Anthropic", Aliases: []string{"claude"}},
|
||||
{ID: "google", Name: "Google", Aliases: []string{"gemini"}},
|
||||
{ID: "groq", Name: "Groq", Aliases: []string{}},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
query string
|
||||
want []string
|
||||
}{
|
||||
{"openai", []string{"openai"}},
|
||||
{"anthropic", []string{"anthropic"}},
|
||||
{"claude", []string{"anthropic"}},
|
||||
{"gemini", []string{"google"}},
|
||||
{"chatgpt", []string{"openai"}},
|
||||
{"groq", []string{"groq"}},
|
||||
{"GROQ", []string{"groq"}},
|
||||
{"nomatch", nil},
|
||||
{"", []string{"openai", "anthropic", "google", "groq"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run("query="+tt.query, func(t *testing.T) {
|
||||
wizard.providerSearch = tt.query
|
||||
got := wizard.filteredProviders()
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("filteredProviders(%q) = %d results, want %d", tt.query, len(got), len(tt.want))
|
||||
}
|
||||
for i, id := range tt.want {
|
||||
if got[i].ID != id {
|
||||
t.Fatalf("filteredProviders(%q)[%d] = %q, want %q", tt.query, i, got[i].ID, id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSearchResetsSelectedProviderOnEdit(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = openProviderWizardForTest(t, m)
|
||||
|
||||
// Move to a later provider, then type a search query.
|
||||
m.providerWizard.selectedProvider = 3
|
||||
updated, _ := m.Update(testKeyText("anth"))
|
||||
next := updated.(model)
|
||||
if next.providerWizard.selectedProvider != 0 {
|
||||
t.Fatalf("selectedProvider after typing = %d, want 0 (reset on edit)", next.providerWizard.selectedProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSearchFilteredAdvanceSelectsCorrectProvider(t *testing.T) {
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepProvider,
|
||||
providers: []providercatalog.Descriptor{
|
||||
{ID: "openai", Name: "OpenAI", AuthEnvVars: []string{"OPENAI_API_KEY"}, RequiresAuth: true},
|
||||
{ID: "anthropic", Name: "Anthropic", AuthEnvVars: []string{"ANTHROPIC_API_KEY"}, RequiresAuth: true},
|
||||
{ID: "google", Name: "Google", AuthEnvVars: []string{"GOOGLE_API_KEY"}, RequiresAuth: true},
|
||||
{ID: "groq", Name: "Groq", AuthEnvVars: []string{"GROQ_API_KEY"}, RequiresAuth: true},
|
||||
},
|
||||
selectedProvider: 0,
|
||||
}
|
||||
|
||||
// Type a search that matches exactly "groq".
|
||||
wizard.providerSearch = "groq"
|
||||
filtered := wizard.filteredProviders()
|
||||
if len(filtered) != 1 || filtered[0].ID != "groq" {
|
||||
t.Fatalf("expected exactly groq in filtered results, got %v", providerWizardIDs(filtered))
|
||||
}
|
||||
|
||||
// currentProvider() via filtered list should be groq.
|
||||
if got := wizard.currentProvider().ID; got != "groq" {
|
||||
t.Fatalf("currentProvider() = %q, want groq", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSearchEmptyMatchEnterIsNoop(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = openProviderWizardForTest(t, m)
|
||||
|
||||
// Type a query that matches nothing.
|
||||
updated, _ := m.Update(testKeyText("zzzzz"))
|
||||
next := updated.(model)
|
||||
if len(next.providerWizard.filteredProviders()) != 0 {
|
||||
t.Fatal("expected zero filtered providers")
|
||||
}
|
||||
|
||||
// Enter should be a no-op.
|
||||
updated, _ = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
if next.providerWizard.step != providerWizardStepProvider {
|
||||
t.Fatalf("wizard step = %v, want provider (empty match Enter should be no-op)", next.providerWizard.step)
|
||||
}
|
||||
if next.providerWizard.providerSearch != "zzzzz" {
|
||||
t.Fatalf("providerSearch = %q, want query preserved on noop", next.providerWizard.providerSearch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSearchClearRestoresFullList(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = openProviderWizardForTest(t, m)
|
||||
|
||||
initialCount := len(m.providerWizard.filteredProviders())
|
||||
if initialCount == 0 {
|
||||
t.Fatal("expected providers in the wizard")
|
||||
}
|
||||
|
||||
// Type a search query.
|
||||
updated, _ := m.Update(testKeyText("open"))
|
||||
next := updated.(model)
|
||||
filtered := next.providerWizard.filteredProviders()
|
||||
if len(filtered) >= initialCount {
|
||||
t.Fatalf("search should narrow results: got %d, want < %d", len(filtered), initialCount)
|
||||
}
|
||||
|
||||
// Ctrl+U clears the search.
|
||||
updated, _ = next.Update(testKeyCtrl('u'))
|
||||
next = updated.(model)
|
||||
restored := next.providerWizard.filteredProviders()
|
||||
if len(restored) != initialCount {
|
||||
t.Fatalf("after clear, filtered count = %d, want %d", len(restored), initialCount)
|
||||
}
|
||||
}
|
||||
|
||||
func providerWizardIDs(descriptors []providercatalog.Descriptor) []string {
|
||||
ids := make([]string, len(descriptors))
|
||||
for i, d := range descriptors {
|
||||
ids[i] = d.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -987,6 +987,12 @@ func isDoctorResultHeading(value string) bool {
|
||||
|
||||
func renderErrorRow(row transcriptRow, width int) string {
|
||||
note := noteBox(row.text, width, zeroTheme.cardErr, zeroTheme.red)
|
||||
// A recognized failure carries a one-line next step. Render it just below the
|
||||
// red box in the faint metadata style so it reads as guidance, not more error
|
||||
// text (and to avoid nesting ANSI styles inside noteBox's per-line red wrap).
|
||||
if hint := strings.TrimSpace(row.hint); hint != "" {
|
||||
note += "\n" + fitStyledLine(zeroTheme.faint.Render("→ "+hint), width)
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
|
||||
@@ -224,14 +224,14 @@ func TestCommandCardRowTrimsIndentedActionsLabel(t *testing.T) {
|
||||
func TestInterimBlockShowsStreamingTextWithCursor(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
m.pending = true
|
||||
m.streamingText = "I'll add a --version flag"
|
||||
m.streamingText = []byte("I'll add a --version flag")
|
||||
got := plainRender(t, m.interimBlock(96))
|
||||
if !strings.Contains(got, "I'll add a --version flag") || !strings.Contains(got, "▌") {
|
||||
t.Fatalf("interim block = %q, want streamed text with trailing cursor", got)
|
||||
}
|
||||
|
||||
// Before the first delta the block falls back to the liveness spinner.
|
||||
m.streamingText = ""
|
||||
m.streamingText = nil
|
||||
if got := plainRender(t, m.interimBlock(96)); !strings.Contains(got, "Working") {
|
||||
t.Fatalf("empty interim block = %q, want the liveness label", got)
|
||||
}
|
||||
@@ -240,13 +240,13 @@ func TestInterimBlockShowsStreamingTextWithCursor(t *testing.T) {
|
||||
func TestInterimBlockRendersStreamingMarkdownTable(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
m.pending = true
|
||||
m.streamingText = strings.Join([]string{
|
||||
m.streamingText = []byte(strings.Join([]string{
|
||||
"Here's the comparison:",
|
||||
"",
|
||||
"| Category | System A | System B |",
|
||||
"|---|---|---|",
|
||||
"| **Label** | Alpha | Beta |",
|
||||
}, "\n")
|
||||
}, "\n"))
|
||||
|
||||
rendered := m.interimBlock(72)
|
||||
got := plainRender(t, rendered)
|
||||
@@ -1528,10 +1528,10 @@ func TestCancelRunClearsStreamingText(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
m.pending = true
|
||||
m.activeRunID = 3
|
||||
m.streamingText = "partial answer from a doomed run"
|
||||
m.streamingText = []byte("partial answer from a doomed run")
|
||||
m.cancelRun()
|
||||
if m.streamingText != "" {
|
||||
t.Fatalf("cancelRun must clear streamingText, got %q", m.streamingText)
|
||||
if len(m.streamingText) != 0 {
|
||||
t.Fatalf("cancelRun must clear streamingText, got %q", string(m.streamingText))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionMatchesWorkspace(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sessionCwd string
|
||||
workspace string
|
||||
wantVisible bool
|
||||
}{
|
||||
{"same workspace", "/home/u/proj", "/home/u/proj", true},
|
||||
{"trailing slash normalizes", "/home/u/proj/", "/home/u/proj", true},
|
||||
{"different workspace hidden", "/home/u/other", "/home/u/proj", false},
|
||||
{"session with no cwd stays visible", "", "/home/u/proj", true},
|
||||
{"unknown current workspace keeps all", "/home/u/other", "", true},
|
||||
// Casing: matched case-insensitively on Windows (its FS is), case-sensitively
|
||||
// elsewhere. This exercises the platform-specific branch on each OS's CI.
|
||||
{"case differs only", "/home/U/Proj", "/home/u/proj", runtime.GOOS == "windows"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := sessionMatchesWorkspace(tc.sessionCwd, tc.workspace); got != tc.wantVisible {
|
||||
t.Fatalf("sessionMatchesWorkspace(%q, %q) = %v, want %v", tc.sessionCwd, tc.workspace, got, tc.wantVisible)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestCommandsRegistered(t *testing.T) {
|
||||
for _, name := range []string{"/retry", "/edit", "/copy", "/export"} {
|
||||
if cmd, ok := resolveCommand(name); !ok {
|
||||
t.Fatalf("%s should be a registered command", name)
|
||||
} else if cmd.name != name {
|
||||
t.Fatalf("resolveCommand(%q) = %q", name, cmd.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /edit recalls the last prompt into the composer for editing.
|
||||
func TestEditRecallsLastPrompt(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.lastPrompt = "refactor the parser"
|
||||
m.input.SetValue("/edit")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if got := next.composerValue(); got != "refactor the parser" {
|
||||
t.Fatalf("/edit should recall last prompt into composer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// /edit must re-stage the remembered attachments alongside the recalled text, so an
|
||||
// edited resend of a vision/PDF-backed prompt carries the same image/document
|
||||
// context instead of silently sending a text-only version.
|
||||
func TestEditRestagesAttachments(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4.1"})
|
||||
m.lastPrompt = "describe the diagram"
|
||||
m.lastImages = []zeroruntime.ImageBlock{{MediaType: "image/png"}}
|
||||
m.lastImageLabels = []string{"diagram.png"}
|
||||
m.lastDocuments = []pendingDocument{{label: "spec.pdf", text: "notes"}}
|
||||
m.input.SetValue("/edit")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if got := next.composerValue(); got != "describe the diagram" {
|
||||
t.Fatalf("/edit should recall the prompt text, got %q", got)
|
||||
}
|
||||
if len(next.pendingImages) != 1 || len(next.pendingImageLabels) != 1 || next.pendingImageLabels[0] != "diagram.png" {
|
||||
t.Fatalf("/edit should re-stage the remembered image, got imgs=%d labels=%v",
|
||||
len(next.pendingImages), next.pendingImageLabels)
|
||||
}
|
||||
if len(next.pendingDocuments) != 1 || next.pendingDocuments[0].label != "spec.pdf" {
|
||||
t.Fatalf("/edit should re-stage the remembered document, got %#v", next.pendingDocuments)
|
||||
}
|
||||
}
|
||||
|
||||
// /retry with no prior prompt reports that there's nothing to resend rather than
|
||||
// launching an empty run.
|
||||
func TestRetryWithoutPriorPromptIsNoOp(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.input.SetValue("/retry")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if !transcriptContains(next.transcript, "no previous prompt") {
|
||||
t.Fatalf("/retry with no history should note there's nothing to resend, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// /retry must not launch a run while compaction is rewriting session state — the
|
||||
// same guard a normal prompt has.
|
||||
func TestRetryBlockedDuringCompaction(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.lastPrompt = "do the thing"
|
||||
m.compactInFlight = true
|
||||
m.input.SetValue("/retry")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if next.pending {
|
||||
t.Fatal("/retry must not start a run during compaction")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("/retry during compaction must not return a run command")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Compaction is running") {
|
||||
t.Fatalf("/retry during compaction should warn, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -25,7 +25,7 @@ func Run(ctx context.Context, options Options) int {
|
||||
|
||||
externalSink := options.RuntimeMessageSink
|
||||
var program *tea.Program
|
||||
options.RuntimeMessageSink = func(msg tea.Msg) {
|
||||
forward := func(msg tea.Msg) {
|
||||
if externalSink != nil {
|
||||
externalSink(msg)
|
||||
}
|
||||
@@ -33,6 +33,10 @@ func Run(ctx context.Context, options Options) int {
|
||||
program.Send(msg)
|
||||
}
|
||||
}
|
||||
// Coalesce streamed assistant-text deltas to ~one frame each so a fast provider
|
||||
// can't drive a full Update→View per token; every other message flushes pending
|
||||
// text first, keeping order intact.
|
||||
options.RuntimeMessageSink = newTextCoalescer(forward).send
|
||||
options.AltScreen = useAltScreen(options)
|
||||
|
||||
programOpts := []tea.ProgramOption{
|
||||
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user