mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ed9c0084 | ||
|
|
b3052dff2f | ||
|
|
2e267bdbee | ||
|
|
739a47e3ea | ||
|
|
ce4a996ffa | ||
|
|
378d538e24 | ||
|
|
015452c1c9 | ||
|
|
af875df587 | ||
|
|
dbd9443014 | ||
|
|
0fae754460 | ||
|
|
2cc6f43bf7 |
No files matched your search
@@ -1,5 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and setup help
|
||||
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
|
||||
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
|
||||
- name: Feature ideas
|
||||
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
|
||||
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/Gitlawb/zero/security/advisories/new
|
||||
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
|
||||
@@ -1,12 +1,22 @@
|
||||
name: Feature request
|
||||
description: Propose a new capability or change
|
||||
name: Feature request (previously discussed)
|
||||
description: File only after your idea gained traction in Discussions Ideas or a maintainer asked for a tracking issue
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please do not begin implementation until the issue has been reviewed and marked with the
|
||||
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
not here. This form is for the step after that: an idea that gained traction in Discussions,
|
||||
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
|
||||
Do not begin implementation until the issue has been reviewed and marked with the
|
||||
`issue-approved` label (see CONTRIBUTING.md).
|
||||
- type: input
|
||||
id: discussion
|
||||
attributes:
|
||||
label: Ideas discussion
|
||||
description: Link to the Discussions Ideas thread (or note which maintainer asked for this issue).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
|
||||
@@ -55,3 +55,6 @@ docs/superpowers/
|
||||
zero-termux-arm64
|
||||
zero-linux-sandbox
|
||||
zero-seccomp
|
||||
|
||||
# Generated benchmark reports are evidence for one configuration, never tree state
|
||||
internal/perfbench/reports/*.json
|
||||
@@ -1,27 +1,71 @@
|
||||
---
|
||||
description: Project guidelines for the open-source CLI coding agent (zero) repository.
|
||||
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
|
||||
alwaysApply: false
|
||||
---
|
||||
# Repository Guidelines for Zero
|
||||
|
||||
# Repository Conventions for Zero
|
||||
These instructions apply to all work in this repository. For the user-facing
|
||||
guide to extending Zero with specialists, hooks, plugins, MCP, and skills, see
|
||||
[docs/EXTENDING.md](docs/EXTENDING.md).
|
||||
|
||||
This file outlines the project conventions and repository guidelines for coding agents when working on the `zero` repository. For the general guide on how to extend Zero (write specialist sub-agents, hooks, plugins, MCP, skills), see [docs/EXTENDING.md](docs/EXTENDING.md).
|
||||
## 1. Contribution and Pull Request Rules
|
||||
|
||||
## 1. Project Conventions
|
||||
- Before opening any pull request, **all contributors**—including maintainers,
|
||||
community contributors, and coding agents—must read and follow
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
- Community pull requests require an existing parent issue with the
|
||||
`issue-approved` label. Team members may open pull requests through the
|
||||
internal development process described in `CONTRIBUTING.md`.
|
||||
- Keep each change focused on the approved or assigned scope. Do not include
|
||||
unrelated fixes, refactors, formatting churn, generated output, or existing
|
||||
local changes in the same commit or pull request.
|
||||
- Discuss new implementation languages, runtimes, major dependency changes,
|
||||
and broad architectural rewrites with maintainers before implementation.
|
||||
- Pull request descriptions must explain what changed and why, link the parent
|
||||
issue when required, and list the tests or verification performed. Include
|
||||
screenshots or a short recording for user-visible UI changes when practical.
|
||||
|
||||
- Build with `make`, not `go build` directly.
|
||||
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
|
||||
- Run `make lint` before opening a PR.
|
||||
- Never edit files under `third_party/` — those are vendored.
|
||||
- Unify functions/methods where possible to prevent codebase inflation. Prefer a single cross-platform function with conditional checks over duplicating helpers per platform.
|
||||
## 2. Repository and Implementation Conventions
|
||||
|
||||
## 2. Guidelines for Coding Agents
|
||||
- Use the Go version declared in `go.mod`. Do not hardcode a different local
|
||||
toolchain version in scripts or documentation.
|
||||
- Use the repository build and release commands (`make` and
|
||||
`go run ./cmd/zero-release ...`) instead of inventing parallel build flows.
|
||||
- Keep tests beside their source files (`foo_test.go` next to `foo.go`). Add a
|
||||
regression test for behavior changes and run affected concurrent code under
|
||||
the race detector.
|
||||
- Never edit files under `third_party/`; they are vendored.
|
||||
- Prefer one cross-platform function with small conditional checks over
|
||||
duplicated platform-specific helpers when the behavior can remain unified.
|
||||
- Do not commit generated benchmark reports from
|
||||
`internal/perfbench/reports/*.json`; reports are configuration-specific
|
||||
evidence, not repository state.
|
||||
- Preserve the user's working tree. Do not overwrite, delete, stage, or commit
|
||||
unrelated tracked or untracked files.
|
||||
|
||||
If you are an AI coding agent executing tasks in this repository, you **MUST** run all Go code quality and security checks before committing code or completing your task:
|
||||
## 3. Required Validation
|
||||
|
||||
1. **Format & Vet**: Run `go fmt ./...` and `go vet ./...`.
|
||||
2. **Lint**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
3. **Security**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
Run validation from the repository root before committing, opening a pull
|
||||
request, or completing an implementation task:
|
||||
|
||||
If any check fails or cannot be run, do not ignore it. Prompt the user for instructions or setup assistance before proceeding.
|
||||
1. **Formatting check**: `make fmt-check`. If it fails, format with
|
||||
`go fmt ./...` (or `make fmt`) and run the check again.
|
||||
2. **Vet**: `go vet ./...` (or `make vet`).
|
||||
3. **Tests**: `go test ./...`. Use `make test` for the full race-enabled suite,
|
||||
or run focused tests with `-race`, when concurrency is affected.
|
||||
4. **Build**: `go run ./cmd/zero-release build`.
|
||||
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
|
||||
6. **Advisory lint**:
|
||||
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
7. **Security**:
|
||||
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
|
||||
tracked changes).
|
||||
|
||||
`make lint` currently runs the formatting check and `go vet`; it does **not**
|
||||
run golangci-lint. The pinned golangci-lint job is advisory in CI while the
|
||||
existing repository-wide backlog is cleaned up. Fix findings introduced by or
|
||||
related to the current change. Unrelated pre-existing advisory findings do not
|
||||
justify expanding a focused pull request; report them separately.
|
||||
|
||||
Formatting, vet, tests, build, smoke, diff hygiene, and govulncheck are hard
|
||||
requirements. If a related check fails, fix it. If a required check cannot run
|
||||
because of the environment or fails for an unrelated external reason, report
|
||||
the exact failure and obtain maintainer direction rather than silently ignoring
|
||||
it.
|
||||
+26
-5
@@ -8,6 +8,13 @@ stabilizes. Community participation is still welcome, especially through bug
|
||||
reports, feature requests, reproduction details, logs, and discussion on existing
|
||||
issues.
|
||||
|
||||
Questions and setup help belong in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
|
||||
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
|
||||
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
|
||||
an idea that gains traction there is the path to an approved issue and,
|
||||
eventually, an accepted PR.
|
||||
|
||||
Please read this policy before opening a pull request. Pull requests that do not
|
||||
follow it may be closed without review.
|
||||
|
||||
@@ -33,7 +40,11 @@ unless the issue has been explicitly approved for outside contribution.
|
||||
There are three primary ways to contribute:
|
||||
|
||||
1. Open a bug report with clear reproduction steps.
|
||||
2. Open a feature request describing the problem, use case, and expected behavior.
|
||||
2. Propose a feature in
|
||||
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
describing the problem, use case, and expected behavior. A feature-request
|
||||
issue is filed only after the idea gains traction there or a maintainer asks
|
||||
for one.
|
||||
3. Add useful context to an existing issue, such as logs, screenshots, examples,
|
||||
affected versions, or reproduction cases.
|
||||
|
||||
@@ -46,7 +57,8 @@ All community pull requests require an approved parent issue.
|
||||
|
||||
Before starting implementation work:
|
||||
|
||||
1. Open an issue using the appropriate issue template.
|
||||
1. For a bug, open an issue using the bug-report template. For a feature, start
|
||||
in Discussions Ideas; the feature-request issue comes after the discussion.
|
||||
2. Describe the bug, feature request, or functional change clearly.
|
||||
3. Wait for the core team to review the issue.
|
||||
4. Do not open a pull request unless the issue has the `issue-approved` label.
|
||||
@@ -133,7 +145,12 @@ Clear bug reports are the most useful way to help the project during this phase.
|
||||
|
||||
## Submitting a Feature Request
|
||||
|
||||
When filing a feature request, please include:
|
||||
Feature requests start as a thread in
|
||||
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
not as an issue. The feature-request issue form exists for the step AFTER that:
|
||||
when an idea has gained traction in Discussions or a maintainer has asked for an
|
||||
issue to track it. Whether in the discussion or the eventual issue, please
|
||||
include:
|
||||
|
||||
- The problem you are trying to solve.
|
||||
- The behavior or functionality you are requesting.
|
||||
@@ -156,5 +173,9 @@ contribution phase.
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you are unsure whether a change is appropriate, open an issue first and ask.
|
||||
Please keep discussion focused, specific, and respectful of maintainer time.
|
||||
General questions, setup help, and "is this a bug or my config" uncertainty all
|
||||
go to
|
||||
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
|
||||
If you are unsure whether a proposed code change is appropriate, raise it in
|
||||
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
|
||||
focused, specific, and respectful of maintainer time.
|
||||
@@ -195,7 +195,7 @@ Useful controls:
|
||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel or exit |
|
||||
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
|
||||
|
||||
Common slash commands:
|
||||
|
||||
@@ -205,6 +205,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
@@ -402,6 +403,24 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
- [Performance](docs/PERFORMANCE.md)
|
||||
- [Agent evals](docs/AGENT_EVALS.md)
|
||||
|
||||
## Community
|
||||
|
||||
Questions, setup help, ideas, and sharing all live in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
|
||||
| Category | Use it for |
|
||||
|---|---|
|
||||
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
|
||||
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
|
||||
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
|
||||
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
|
||||
|
||||
For a good Q&A answer fast, include `zero --version`, your OS and install
|
||||
method, the provider/model in use, and `zero doctor` output. See
|
||||
[SUPPORT.md](SUPPORT.md). Bugs belong in
|
||||
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
|
||||
follow [SECURITY.md](SECURITY.md), never a public thread.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Getting help with Zero
|
||||
|
||||
## Questions, setup help, configuration
|
||||
|
||||
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
|
||||
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
|
||||
be marked and found by the next person.
|
||||
|
||||
The fastest way to a useful answer is to include:
|
||||
|
||||
- `zero --version` output
|
||||
- your OS (Windows, macOS, Linux) and how you installed Zero (npm, install
|
||||
script, source)
|
||||
- the provider and model you are using (`zero providers list`)
|
||||
- `zero doctor` output for setup problems
|
||||
- the exact command you ran and the exact error text
|
||||
|
||||
## Bugs
|
||||
|
||||
If Zero does something wrong (crashes, wrong output, broken behavior), file a
|
||||
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
|
||||
sure whether it is a bug or a setup problem, start in Discussions and we will
|
||||
promote it to an issue if it is one.
|
||||
|
||||
## Feature ideas
|
||||
|
||||
Start a thread in
|
||||
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
|
||||
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
|
||||
also the path to getting a PR accepted.
|
||||
|
||||
## Security issues
|
||||
|
||||
Never file a public issue or discussion. Report privately via
|
||||
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
|
||||
(see SECURITY.md).
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
|
||||
@@ -92,3 +94,18 @@ func TestHelpText(t *testing.T) {
|
||||
func emptyEnv(string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestTurnExitCodeFailsWhenEveryTaskErrored(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
allErrored := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 3}
|
||||
if code := turnExitCode(allErrored, &stderr); code != 1 {
|
||||
t.Fatalf("exit=%d, want 1 when every task errored", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no accepted benchmark sample") {
|
||||
t.Fatalf("stderr missing explanation: %q", stderr.String())
|
||||
}
|
||||
partial := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 2}
|
||||
if code := turnExitCode(partial, &stderr); code != 0 {
|
||||
t.Fatalf("exit=%d, want 0 for partial errors (summary surfaces them)", code)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ type turnOptions struct {
|
||||
SuitePath string
|
||||
Model string
|
||||
Mode string
|
||||
ExecProfile string
|
||||
SelfCorrect bool
|
||||
Binary string
|
||||
Iterations int
|
||||
@@ -64,6 +66,7 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
|
||||
result, err := perfbench.RunTurnBench(context.Background(), set, perfbench.TurnBenchConfig{
|
||||
Model: options.Model,
|
||||
Mode: options.Mode,
|
||||
ExecProfile: options.ExecProfile,
|
||||
SelfCorrect: options.SelfCorrect,
|
||||
Version: options.Version,
|
||||
Commit: options.Commit,
|
||||
@@ -86,9 +89,23 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return turnExitCode(result, stderr)
|
||||
}
|
||||
_, _ = fmt.Fprintln(stdout, perfbench.FormatTurnBenchSummary(result))
|
||||
return turnExitCode(result, stderr)
|
||||
}
|
||||
|
||||
// turnExitCode fails the command when every attempted task errored (no
|
||||
// iteration produced an accepted benchmark sample): such a report measures
|
||||
// nothing, and exiting 0 would let a broken configuration (missing binary, bad
|
||||
// path, failing harness step) pass as a clean baseline. Partial errors keep
|
||||
// exit 0 — the summary surfaces them loudly and the surviving samples are
|
||||
// still valid measurements.
|
||||
func turnExitCode(result perfbench.TurnBenchResult, stderr io.Writer) int {
|
||||
if result.TasksAttempted > 0 && result.TasksErrored == result.TasksAttempted {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: every task errored with no accepted benchmark sample (see warnings); the report contains no valid measurements")
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -123,6 +140,13 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
|
||||
}
|
||||
options.Mode = value
|
||||
index = next
|
||||
case "--exec-profile":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.ExecProfile = value
|
||||
index = next
|
||||
case "--binary":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
@@ -195,6 +219,20 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
|
||||
if strings.TrimSpace(options.Model) == "" && !options.DryRun {
|
||||
return options, fmt.Errorf("--model is required (or pass --dry-run)")
|
||||
}
|
||||
// Validate the profile here, before anything spawns: an unknown name would
|
||||
// otherwise make every child exit with a usage error in milliseconds, and
|
||||
// those near-zero walls would be recorded as valid latency samples in an
|
||||
// exit-0 report — exactly the misread-as-improvement trap the harness
|
||||
// closed for spawn failures. Normalizing to the catalog name also keeps
|
||||
// the stamped execProfile canonical (Lookup is case-insensitive), so two
|
||||
// captures of the same posture always compare equal.
|
||||
if raw := strings.TrimSpace(options.ExecProfile); raw != "" {
|
||||
profile, ok := execprofile.Lookup(raw)
|
||||
if !ok {
|
||||
return options, fmt.Errorf("unknown execution profile %q for --exec-profile. Valid profiles: %s", raw, strings.Join(execprofile.Names(), ", "))
|
||||
}
|
||||
options.ExecProfile = profile.Name
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
@@ -225,6 +263,9 @@ func turnHelpText() string {
|
||||
" --suite <path> Task set JSON file (required)",
|
||||
" --model <model> Model to run (required unless --dry-run)",
|
||||
" --mode <name> Exec mode preset to apply",
|
||||
" --exec-profile <name>",
|
||||
" Execution profile for every task (fast|balanced|thorough);",
|
||||
" forwarded to zero exec and stamped into the result",
|
||||
" --self-correct Enable the post-edit verify-and-correct loop",
|
||||
" --binary <path> Path to the `zero` binary (default: zero on PATH / repo root)",
|
||||
" --iterations <n> Times to run each task (default: 1)",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTurnArgsExecProfile(t *testing.T) {
|
||||
getenv := func(string) string { return "" }
|
||||
options, err := parseTurnArgs([]string{
|
||||
"--suite", "suite.json",
|
||||
"--model", "m",
|
||||
"--exec-profile", "fast",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "fast" {
|
||||
t.Fatalf("ExecProfile = %q, want fast", options.ExecProfile)
|
||||
}
|
||||
|
||||
// Inline form.
|
||||
options, err = parseTurnArgs([]string{
|
||||
"--suite=suite.json",
|
||||
"--model=m",
|
||||
"--exec-profile=thorough",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs inline: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "thorough" {
|
||||
t.Fatalf("ExecProfile = %q, want thorough", options.ExecProfile)
|
||||
}
|
||||
}
|
||||
|
||||
// The bench validates the profile BEFORE spawning anything: an unknown name
|
||||
// would otherwise make every child exit with a usage error in milliseconds
|
||||
// and record those near-zero walls as valid latency samples in an exit-0
|
||||
// report. Lookup normalization also keeps the stamped name canonical so two
|
||||
// captures of the same posture compare equal.
|
||||
func TestParseTurnArgsExecProfileValidatesAndNormalizes(t *testing.T) {
|
||||
getenv := func(string) string { return "" }
|
||||
|
||||
options, err := parseTurnArgs([]string{
|
||||
"--suite", "suite.json", "--model", "m", "--exec-profile", "FAST",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "fast" {
|
||||
t.Fatalf("ExecProfile = %q, want the canonical fast", options.ExecProfile)
|
||||
}
|
||||
|
||||
_, err = parseTurnArgs([]string{
|
||||
"--suite", "suite.json", "--model", "m", "--exec-profile", "blanced",
|
||||
}, getenv)
|
||||
if err == nil {
|
||||
t.Fatal("an unknown profile must fail parse, before anything spawns")
|
||||
}
|
||||
for _, name := range []string{"balanced", "fast", "thorough"} {
|
||||
if !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("error must list %q, got %q", name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ type CompactionOptions struct {
|
||||
// injected so Compact stays pure and testable; the agent loop wires it to a
|
||||
// real provider call.
|
||||
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
||||
// taskState is a snapshot supplied by the running agent. Its immutable
|
||||
// objective is always preserved; mutable fields are admitted only when its
|
||||
// plan projection still matches the transcript.
|
||||
taskState *taskStateSnapshot
|
||||
}
|
||||
|
||||
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
||||
@@ -220,7 +224,7 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
|
||||
|
||||
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
||||
compacted = append(compacted, messages[:systemEnd]...)
|
||||
@@ -326,6 +330,7 @@ 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)
|
||||
task *taskState
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
@@ -365,13 +370,15 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
return int(float64(raw) * state.calibrationRatio)
|
||||
}
|
||||
|
||||
func newCompactionState(options Options) *compactionState {
|
||||
return &compactionState{
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
@@ -418,6 +425,7 @@ func (state *compactionState) maybeCompact(
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
// Summarizer failed: keep the original history. The reactive path (or a
|
||||
@@ -468,6 +476,7 @@ func (state *compactionState) recover(
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
// A genuine compaction attempt was made (and failed): the budget is spent
|
||||
|
||||
@@ -30,6 +30,10 @@ const (
|
||||
const (
|
||||
maxRecentEdits = 20
|
||||
maxEditNoteBytes = 160
|
||||
// maxTaskObjectiveBytes keeps the original objective recognizable after
|
||||
// compaction without allowing an unusually large prompt to recreate the
|
||||
// context pressure the compaction just removed.
|
||||
maxTaskObjectiveBytes = 512
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -76,32 +80,14 @@ func extractLatestPlan(messages []zeroruntime.Message) string {
|
||||
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
|
||||
// "" on malformed arguments or an empty plan.
|
||||
func formatPlanArguments(arguments string) string {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Content string `json:"content"`
|
||||
Step string `json:"step"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(parsed.Plan))
|
||||
for _, item := range parsed.Plan {
|
||||
content := strings.TrimSpace(item.Content)
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(item.Step)
|
||||
}
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
status := strings.TrimSpace(item.Status)
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
line := "- [" + status + "] " + content
|
||||
if notes := strings.TrimSpace(item.Notes); notes != "" {
|
||||
lines := make([]string, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
line := "- [" + item.Status + "] " + item.Content
|
||||
if notes := item.Notes; notes != "" {
|
||||
line += "\n Notes: " + notes
|
||||
}
|
||||
lines = append(lines, line)
|
||||
@@ -431,12 +417,25 @@ func capBody(body string) string {
|
||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||
type preservedState struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Task *preservedTaskState `json:"task,omitempty"`
|
||||
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||
Tools []preservedTool `json:"tools,omitempty"`
|
||||
Skills []preservedSkill `json:"skills,omitempty"`
|
||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||
}
|
||||
|
||||
type preservedTaskState struct {
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
Path string `json:"path"`
|
||||
Note string `json:"note,omitempty"`
|
||||
@@ -464,8 +463,27 @@ type preservedInstruction struct {
|
||||
// may live only inside the injected summary message, which on a later compaction
|
||||
// lands in middle with no real tool calls left to extract. Fresh tool calls and
|
||||
// instruction blocks override the carried-forward copy by name/source.
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnapshot *taskStateSnapshot) string {
|
||||
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
}
|
||||
// Plan parity corroborates only the mutable task projection. The objective
|
||||
// comes directly from the run prompt and is immutable, so it must survive
|
||||
// even after compaction removes the plan tool call needed for comparison.
|
||||
if taskSnapshot.PlanParity == taskPlanParityMatch {
|
||||
task.Status = taskSnapshot.Status
|
||||
task.Pending = taskSnapshot.Plan.Pending
|
||||
task.InProgress = taskSnapshot.Plan.InProgress
|
||||
task.Completed = taskSnapshot.Plan.Completed
|
||||
task.Failed = taskSnapshot.Plan.Failed
|
||||
task.VerificationPassed = taskSnapshot.Verification.Passed
|
||||
task.VerificationFailed = taskSnapshot.Verification.Failed
|
||||
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
||||
}
|
||||
}
|
||||
|
||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||
// plan preserved by an earlier compaction.
|
||||
@@ -493,12 +511,25 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
projectInstructionEntries(middle),
|
||||
)
|
||||
|
||||
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
|
||||
if block := formatPreservedState(plan, task, edits, tools, skills, instructions); block != "" {
|
||||
summary += "\n\n" + block
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func capTaskObjective(objective string) string {
|
||||
objective = strings.TrimSpace(objective)
|
||||
if len(objective) <= maxTaskObjectiveBytes {
|
||||
return objective
|
||||
}
|
||||
const suffix = "…"
|
||||
limit := maxTaskObjectiveBytes - len(suffix)
|
||||
for limit > 0 && !utf8.RuneStart(objective[limit]) {
|
||||
limit--
|
||||
}
|
||||
return strings.TrimSpace(objective[:limit]) + suffix
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -555,11 +586,11 @@ func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
||||
|
||||
// formatPreservedState renders state as the labelled, single-line
|
||||
// JSON block. Returns "" when there is nothing to preserve.
|
||||
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
func formatPreservedState(plan string, task *preservedTaskState, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && task == nil && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
return ""
|
||||
}
|
||||
state := preservedState{Plan: plan}
|
||||
state := preservedState{Plan: plan, Task: task}
|
||||
for _, e := range edits {
|
||||
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
@@ -59,6 +60,103 @@ func TestCompactPreservesActivePlan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
objective := strings.Repeat("世", maxTaskObjectiveBytes)
|
||||
task := newTaskState(objective, nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Status != taskStatusActive || state.Task.InProgress != 1 || state.Task.Pending != 1 {
|
||||
t.Fatalf("unexpected compact task state: %#v", state.Task)
|
||||
}
|
||||
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
||||
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
||||
prior := preservedState{Task: &preservedTaskState{Objective: "stale objective", Status: taskStatusActive, Pending: 1}}
|
||||
encoded, err := json.Marshal(prior)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\nold\n\n" + preservedStateLabel + "\n" + string(encoded)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
}
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: &taskStateSnapshot{
|
||||
Objective: "current objective",
|
||||
PlanParity: taskPlanParityMismatch,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "current objective" {
|
||||
t.Fatalf("immutable objective was lost on plan mismatch: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.Pending != 0 {
|
||||
t.Fatalf("uncorroborated mutable task fields survived plan mismatch: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskObjectiveSurvivesRepeatedCompactionWithoutPlanRefresh(t *testing.T) {
|
||||
task := newTaskState("keep this objective", nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
|
||||
first, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Compact: %v", err)
|
||||
}
|
||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
)
|
||||
snapshot := task.snapshotForCompaction(secondInput)
|
||||
if snapshot.PlanParity != taskPlanParityMismatch {
|
||||
t.Fatalf("plan call should be absent after first compaction, parity=%q", snapshot.PlanParity)
|
||||
}
|
||||
second, err := Compact(secondInput, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||
taskState: snapshot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(second[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "keep this objective" {
|
||||
t.Fatalf("objective lost after second compaction: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.InProgress != 0 {
|
||||
t.Fatalf("uncorroborated mutable fields survived second compaction: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesLoadedSkills(t *testing.T) {
|
||||
summary := compactStateConversation(t, stateConversation())
|
||||
if !strings.Contains(summary, preservedStateLabel) {
|
||||
@@ -283,12 +381,10 @@ func TestExtractLatestPlanReturnsMostRecent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPlanArgumentsAcceptsStepAlias(t *testing.T) {
|
||||
func TestFormatPlanArgumentsRejectsUnsupportedStepAlias(t *testing.T) {
|
||||
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
|
||||
for _, want := range []string{"- [in_progress] write failing test", "- [pending] keep existing shape"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q in formatted plan, got %q", want, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("unsupported alias should reject the whole plan like update_plan, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -581,7 +581,7 @@ func TestCompactNeverProducesConsecutiveUserMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2})
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
@@ -628,7 +628,7 @@ func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverDisabledIsNoop(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 0})
|
||||
st := newCompactionState(Options{ContextWindow: 0}, nil)
|
||||
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
|
||||
called := false
|
||||
// recover must not invoke the provider/summarize when disabled, even on a
|
||||
|
||||
@@ -39,7 +39,7 @@ func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
|
||||
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
|
||||
}
|
||||
|
||||
func (policy *completionPolicy) evaluate(text string, planPending bool) completionEvaluation {
|
||||
func (policy *completionPolicy) evaluate(text string, context completionContext) completionEvaluation {
|
||||
// A direct admission is strong local evidence and takes precedence over all
|
||||
// weaker signals, avoiding both continuation nudges and semantic checks.
|
||||
if reason := selfReportedIncompletion(text); reason != "" {
|
||||
@@ -50,7 +50,7 @@ func (policy *completionPolicy) evaluate(text string, planPending bool) completi
|
||||
// weak evidence because bookkeeping can be stale. Both get a bounded chance
|
||||
// to continue, but only a persisted cue is ultimately incomplete.
|
||||
cue := endsWithContinuationCue(text)
|
||||
if cue || planPending {
|
||||
if cue || context.PlanPending {
|
||||
if policy.continueNudges < maxContinueNudges {
|
||||
policy.continueNudges++
|
||||
reason := "your message ended mid-step"
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
|
||||
complete := policy.evaluate("Done. All required checks pass.", false)
|
||||
complete := policy.evaluate("Done. All required checks pass.", completionContext{})
|
||||
if complete.Decision != CompletionComplete {
|
||||
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
|
||||
}
|
||||
|
||||
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", false)
|
||||
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", completionContext{})
|
||||
if incomplete.Decision != CompletionIncomplete {
|
||||
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
|
||||
}
|
||||
@@ -19,13 +22,13 @@ func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T)
|
||||
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionIncomplete {
|
||||
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
|
||||
}
|
||||
@@ -34,13 +37,13 @@ func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
||||
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||
got := policy.evaluate("All set.", true)
|
||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
got := policy.evaluate("All set.", true)
|
||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionComplete {
|
||||
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
|
||||
}
|
||||
@@ -49,12 +52,12 @@ func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
||||
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
||||
policy := newCompletionPolicy(true)
|
||||
|
||||
first := policy.evaluate("Implemented and tested.", false)
|
||||
first := policy.evaluate("Implemented and tested.", completionContext{})
|
||||
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
|
||||
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
|
||||
}
|
||||
|
||||
second := policy.evaluate("PASS. The result meets the task criterion.", false)
|
||||
second := policy.evaluate("PASS. The result meets the task criterion.", completionContext{})
|
||||
if second.Decision != CompletionComplete {
|
||||
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
|
||||
}
|
||||
@@ -62,3 +65,11 @@ func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
||||
t.Fatal("semantic check was requested more than once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceVerificationNudgeIncludesBoundedObjective(t *testing.T) {
|
||||
objective := "make the requested behavior observable"
|
||||
nudge := acceptanceVerificationNudge(objective)
|
||||
if !strings.Contains(nudge, "Task objective: "+objective) {
|
||||
t.Fatalf("semantic check was not grounded in the objective: %q", nudge)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -127,9 +126,8 @@ func endsWithContinuationCue(text string) bool {
|
||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||
// that tool's own status normalization.
|
||||
func planStatusRemaining(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
|
||||
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
switch normalizeTaskPlanStatus(status) {
|
||||
case "completed", "failed":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -307,8 +305,8 @@ func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||
// passing treated as the objective being met; and a result that merely matches a
|
||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||
func acceptanceVerificationNudge() string {
|
||||
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
func acceptanceVerificationNudge(objective string) string {
|
||||
nudge := "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||
"result merely matches a baseline you were asked to beat or improve. " +
|
||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||
@@ -316,6 +314,10 @@ func acceptanceVerificationNudge() string {
|
||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||
"If that check passes, reply PASS and cite the evidence. " +
|
||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||
if objective = capTaskObjective(objective); objective != "" {
|
||||
nudge += "\n\nTask objective: " + objective
|
||||
}
|
||||
return nudge
|
||||
}
|
||||
|
||||
// toolFailureHintMarker is a stable substring for tests.
|
||||
@@ -553,16 +555,12 @@ func (state *guardState) pendingPlanItems() bool {
|
||||
// many items are still remaining. Malformed arguments leave the prior count
|
||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||
func (state *guardState) observePlanUpdate(arguments string) {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if json.Unmarshal([]byte(arguments), &parsed) != nil {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pending := 0
|
||||
for _, item := range parsed.Plan {
|
||||
for _, item := range plan {
|
||||
if planStatusRemaining(item.Status) {
|
||||
pending++
|
||||
}
|
||||
|
||||
+88
-13
@@ -181,10 +181,46 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.runPermissions = runPermissions
|
||||
defer runPermissions.cleanup()
|
||||
|
||||
messages := zeroruntime.SeedMessagesWithImages(buildSystemPrompt(options), prompt, options.Images)
|
||||
promptParts := buildSystemPromptParts(options)
|
||||
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
|
||||
|
||||
guards := newGuardState()
|
||||
compactor := newCompactionState(options)
|
||||
task := newTaskState(prompt, options.Trace)
|
||||
compactor := newCompactionState(options, task)
|
||||
defer func() {
|
||||
// A final transcript comparison is observational. It records drift but
|
||||
// never rewrites the result or changes execution after the fact.
|
||||
task.observePlanParity(result.Messages)
|
||||
// Tool-result observations are intentionally coalesced; this final event
|
||||
// guarantees their aggregate counts are present even on an early return.
|
||||
task.emit()
|
||||
}()
|
||||
// The execution-profile posture controller. nil Options.Profile (every
|
||||
// existing caller) makes every observe/decide call a no-op, keeping the
|
||||
// loop byte-identical for unprofiled runs.
|
||||
posture := newProfileController(options.Profile)
|
||||
// applyPosture applies at most one posture escalation when an armed trigger
|
||||
// has fired: raise the hoisted turn ceiling, restore effort and the
|
||||
// completion gate, stamp the counter. Shared by the end-of-turn tail and
|
||||
// the completion-gate uncertain path (which continues the loop before the
|
||||
// tail runs). No-op forever after the first escalation and for unprofiled
|
||||
// runs.
|
||||
applyPosture := func() {
|
||||
if target, fired := posture.maybeEscalate(); fired {
|
||||
if target.MaxTurns > maxTurns {
|
||||
maxTurns = target.MaxTurns
|
||||
}
|
||||
if target.ReasoningEffort != "" {
|
||||
options.ReasoningEffort = target.ReasoningEffort
|
||||
} else if target.RestoreDefaultEffort {
|
||||
options.ReasoningEffort = ""
|
||||
}
|
||||
if target.RestoreCompletionGate {
|
||||
options.RequireCompletionSignal = true
|
||||
}
|
||||
options.Trace.Counter(trace.CounterPostureEscalations, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Background post-edit diagnostics: files changed by mutating tools are
|
||||
// checked off the tool-call critical path and any errors are appended as a
|
||||
@@ -242,16 +278,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
toolPartitionSpan.End()
|
||||
|
||||
// Prompt-prefix fingerprint: hash the seven cacheable sub-components
|
||||
// of this turn's request (system prompt sections, project context,
|
||||
// skills, partitioned tool list, tool schemas) and emit one trace
|
||||
// event. Stable across turns => cacheable. Drift in any sub-hash
|
||||
// names the sub-component that broke the cache for this turn. The
|
||||
// computation is pure and cheap (seven SHA-256s of small strings)
|
||||
// and is a no-op when tracing is off.
|
||||
// Fingerprint the exact system prompt built at run start plus this turn's
|
||||
// provider-visible tool definitions in their emitted order. Reusing the
|
||||
// retained prompt parts avoids rebuilding workspace context while ensuring
|
||||
// the trace describes the same bytes carried by the request.
|
||||
if options.Trace != nil {
|
||||
fp := ComputePrefixFingerprint(options, exposed)
|
||||
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
|
||||
options.Trace.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fp.SystemPromptHash,
|
||||
BaseInstructionsHash: fp.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fp.ProjectContextHash,
|
||||
@@ -545,7 +579,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// model's final answer ONLY when the work is actually done. Default off
|
||||
// (RequireCompletionSignal), so interactive runs stay byte-identical.
|
||||
if options.RequireCompletionSignal {
|
||||
evaluation := completionPolicy.evaluate(collected.Text, guards.pendingPlanItems())
|
||||
completionContext := task.completionContext(messages, guards.pendingPlanItems())
|
||||
evaluation := completionPolicy.evaluate(collected.Text, completionContext)
|
||||
task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: evaluation})
|
||||
switch evaluation.Decision {
|
||||
case CompletionIncomplete:
|
||||
result.Incomplete = true
|
||||
@@ -554,6 +590,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
case CompletionUncertain:
|
||||
// Observe the uncertainty signal and apply any escalation NOW:
|
||||
// this branch continues the turn loop before the end-of-turn
|
||||
// act point, so deferring would skip escalation entirely.
|
||||
posture.observeUncertain()
|
||||
applyPosture()
|
||||
switch evaluation.Action {
|
||||
case completionActionContinue:
|
||||
options.Trace.Counter(trace.CounterCompletionNudges, 1)
|
||||
@@ -565,13 +606,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.Trace.Counter(trace.CounterAcceptanceChecks, 1)
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: acceptanceVerificationNudge(),
|
||||
Content: acceptanceVerificationNudge(completionContext.Objective),
|
||||
})
|
||||
}
|
||||
continue
|
||||
case CompletionComplete:
|
||||
// Local evidence is sufficient; proceed to final diagnostics.
|
||||
}
|
||||
} else {
|
||||
task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
||||
}
|
||||
// Finalization diagnostics gate: edits from this run may still have
|
||||
// checks in flight — the per-turn drain waits only briefly and defers
|
||||
@@ -595,6 +638,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// executing so the empty-turn counter resets and plan-tracking signals
|
||||
// stay current.
|
||||
guards.observeTurn(collected)
|
||||
for _, call := range collected.ToolCalls {
|
||||
if call.Name == planToolName {
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: call.Arguments})
|
||||
}
|
||||
}
|
||||
|
||||
failureHint := ""
|
||||
// turnRequestedModel records the FIRST mid-run escalation target requested
|
||||
@@ -646,6 +694,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterToolCalls, 1)
|
||||
recordOutputBudgetTrace(options.Trace, toolResult)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -692,6 +741,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// would misdirect the model toward JSON shape or blocked behavior.
|
||||
retriableFailure := isRetriableToolError(toolResult)
|
||||
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
|
||||
posture.observeToolOutcome(outcome, toolResult)
|
||||
if outcome.Stop {
|
||||
// The assistant message advertised EVERY collected tool call, but
|
||||
// the guard halts mid-turn so the calls after this one never run.
|
||||
@@ -724,7 +774,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// the assistant's tool_results stay contiguous (a user message between
|
||||
// tool_results breaks strict provider replay). nil SelfCorrect is a no-op.
|
||||
if options.SelfCorrect != nil && len(changedFilesThisBatch) > 0 {
|
||||
if feedback, _ := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch)); feedback != "" {
|
||||
feedback, selfCorrectOutcome := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch))
|
||||
posture.observeSelfCorrect(selfCorrectOutcome)
|
||||
task.observe(taskStateEvent{kind: taskStateEventVerification, verification: selfCorrectOutcome})
|
||||
if feedback != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: feedback,
|
||||
@@ -817,6 +870,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
|
||||
// One-shot posture escalation: when an armed profile trigger fired this
|
||||
// turn, restore the stricter knob values the profile displaced. Mutates
|
||||
// only per-turn-read policy (the hoisted turn ceiling, reasoning effort,
|
||||
// completion gate); never messages, model, or session. No-op forever
|
||||
// after the first escalation and for every unprofiled run.
|
||||
applyPosture()
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
@@ -1338,10 +1398,25 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
result = registry.RebudgetAfterHook(call.Name, args, result)
|
||||
}
|
||||
}
|
||||
// Stamp the sandbox risk classification for this EXECUTED call so run-policy
|
||||
// observers can see the risk of an allowed mutation. Prefer the preflight
|
||||
// decision's classification when the sandbox evaluated the call; otherwise
|
||||
// run the same pure classifier the permission path uses. Denied/canceled
|
||||
// results returned earlier keep the zero value.
|
||||
executedRisk := sandbox.Risk{}
|
||||
if preflightDecision != nil {
|
||||
executedRisk = preflightDecision.Risk
|
||||
} else if toolFound {
|
||||
// Unknown-tool results fall through here with a nil tool; they carry a
|
||||
// denial and keep the zero risk value.
|
||||
executedRisk = sandbox.Classify(sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options))
|
||||
}
|
||||
|
||||
// Secret scrubbing happens at the registry boundary (the single point both
|
||||
// the agent loop and the MCP server pass through), so result.Output is
|
||||
// already redacted here and result.Redacted reflects whether it changed.
|
||||
return ToolResult{
|
||||
Risk: executedRisk,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1258,6 +1259,46 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesRequestPrefixAcrossTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
if _, err := Run(context.Background(), "read notes", provider, Options{
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
SessionID: "session-stable-prefix",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("provider requests = %d, want 2", len(provider.requests))
|
||||
}
|
||||
first, second := provider.requests[0], provider.requests[1]
|
||||
if len(second.Messages) < len(first.Messages) || !reflect.DeepEqual(second.Messages[:len(first.Messages)], first.Messages) {
|
||||
t.Fatalf("first request messages must be an exact prefix of the second:\nfirst=%#v\nsecond=%#v", first.Messages, second.Messages)
|
||||
}
|
||||
if !reflect.DeepEqual(first.Tools, second.Tools) {
|
||||
t.Fatalf("tool definitions drifted between turns:\nfirst=%#v\nsecond=%#v", first.Tools, second.Tools)
|
||||
}
|
||||
if first.PromptCacheKey != "session-stable-prefix" || second.PromptCacheKey != first.PromptCacheKey {
|
||||
t.Fatalf("prompt cache key must remain stable: first=%q second=%q", first.PromptCacheKey, second.PromptCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// profileController observes per-turn run signals and decides at most one
|
||||
// posture escalation per run for the armed execution profile. A nil policy (or
|
||||
// a policy without Escalate) makes every method a no-op, so runs without a
|
||||
// profile are byte-identical to before — the same opt-in convention as Trace
|
||||
// and SelfCorrect.
|
||||
//
|
||||
// The controller only ever CHANGES loop policy knobs (turn ceiling, reasoning
|
||||
// effort, completion gate) through the values the loop applies at its act
|
||||
// point; it never injects messages, never touches the model or session, and
|
||||
// never interacts with the model-initiated escalate_model path.
|
||||
type profileController struct {
|
||||
policy *PostureEscalation
|
||||
|
||||
escalated bool
|
||||
uncertainSeen int
|
||||
|
||||
failureTripped bool
|
||||
riskTripped bool
|
||||
scTripped bool
|
||||
uncertainTrip bool
|
||||
}
|
||||
|
||||
// newProfileController is nil-safe: a nil ProfilePolicy (the default for every
|
||||
// existing caller) or a nil Escalate yields a controller that observes and
|
||||
// decides nothing.
|
||||
func newProfileController(policy *ProfilePolicy) *profileController {
|
||||
if policy == nil {
|
||||
return &profileController{}
|
||||
}
|
||||
return &profileController{policy: policy.Escalate}
|
||||
}
|
||||
|
||||
// observeToolOutcome watches two signals from an executed tool call: the
|
||||
// repeated-failure guard's streak (reusing the guard's own counting, not a
|
||||
// second failure model) and the sandbox risk classification of an executed
|
||||
// result.
|
||||
func (c *profileController) observeToolOutcome(outcome toolFailureOutcome, result ToolResult) {
|
||||
if c.policy == nil || c.escalated {
|
||||
return
|
||||
}
|
||||
if c.policy.OnToolFailureStreak > 0 && outcome.Count >= c.policy.OnToolFailureStreak {
|
||||
c.failureTripped = true
|
||||
}
|
||||
if threshold := riskRank(c.policy.OnRiskyMutation); threshold > 0 &&
|
||||
result.DenialReason == DenialNone &&
|
||||
riskRank(result.Risk.Level) >= threshold {
|
||||
// The call EXECUTED (was not denied): partial failures count too, since
|
||||
// the mutation ran. An unrecognized threshold ranks 0 and disables the
|
||||
// signal instead of matching everything.
|
||||
c.riskTripped = true
|
||||
}
|
||||
}
|
||||
|
||||
// observeUncertain counts uncertain completion evaluations (continue nudges and
|
||||
// semantic checks). Headless-only by construction: the completion gate never
|
||||
// runs interactively.
|
||||
func (c *profileController) observeUncertain() {
|
||||
if c.policy == nil || c.escalated || c.policy.OnCompletionUncertain <= 0 {
|
||||
return
|
||||
}
|
||||
c.uncertainSeen++
|
||||
if c.uncertainSeen >= c.policy.OnCompletionUncertain {
|
||||
c.uncertainTrip = true
|
||||
}
|
||||
}
|
||||
|
||||
// observeSelfCorrect watches the post-edit verification outcome. Any failing
|
||||
// outcome (correcting, reported, aborted) is a signal; passed and disabled are
|
||||
// not.
|
||||
func (c *profileController) observeSelfCorrect(outcome Outcome) {
|
||||
if c.policy == nil || c.escalated || !c.policy.OnSelfCorrectFailure {
|
||||
return
|
||||
}
|
||||
switch outcome {
|
||||
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
|
||||
c.scTripped = true
|
||||
}
|
||||
}
|
||||
|
||||
// maybeEscalate reports the escalation target exactly once, the first time any
|
||||
// armed trigger has fired. Subsequent calls return false for the rest of the
|
||||
// run: escalation is one-shot and never de-escalates, so no cooldown state is
|
||||
// needed.
|
||||
func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
|
||||
if c.policy == nil || c.escalated {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
c.escalated = true
|
||||
return *c.policy, true
|
||||
}
|
||||
|
||||
// riskRank orders sandbox risk levels for threshold comparison. Unknown levels
|
||||
// rank 0: an unrecognized RESULT level can never meet a valid threshold, and an
|
||||
// unrecognized THRESHOLD is rejected before comparison (rank 0 disables the
|
||||
// signal) so a typo in a profile can never make every result match.
|
||||
func riskRank(level sandbox.RiskLevel) int {
|
||||
switch level {
|
||||
case sandbox.RiskLow:
|
||||
return 1
|
||||
case sandbox.RiskMedium:
|
||||
return 2
|
||||
case sandbox.RiskHigh:
|
||||
return 3
|
||||
case sandbox.RiskCritical:
|
||||
return 4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
|
||||
for _, controller := range []*profileController{
|
||||
newProfileController(nil),
|
||||
newProfileController(&ProfilePolicy{Name: "balanced"}), // Escalate nil
|
||||
} {
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 99}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
controller.observeUncertain()
|
||||
controller.observeSelfCorrect(OutcomeAborted)
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("nil policy must never escalate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnFailureStreak(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 2, MaxTurns: 80}})
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("streak below threshold must not escalate")
|
||||
}
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 2}, ToolResult{Status: tools.StatusError})
|
||||
target, fired := controller.maybeEscalate()
|
||||
if !fired || target.MaxTurns != 80 {
|
||||
t.Fatalf("expected escalation with the policy target, got fired=%t target=%+v", fired, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnRiskyMutation(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskHigh}})
|
||||
// Medium risk on an executed result: below threshold.
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskMedium}})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("medium risk must not trip a high threshold")
|
||||
}
|
||||
// Critical risk on a DENIED result: never counts (the mutation never ran).
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, DenialReason: DenialSandboxBlock, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("a denied result must not trip the risky-mutation signal")
|
||||
}
|
||||
// Critical risk on an executed PARTIAL FAILURE (error status, no denial):
|
||||
// the mutation ran, so it counts.
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("an executed partial failure at critical risk must trip a high threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerIgnoresInvalidRiskThreshold(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskLevel("hgih")}})
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("an unrecognized threshold must disable the signal, not match every result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnSelfCorrectFailure(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnSelfCorrectFailure: true}})
|
||||
controller.observeSelfCorrect(OutcomePassed)
|
||||
controller.observeSelfCorrect(OutcomeDisabled)
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("passing/disabled outcomes must not escalate")
|
||||
}
|
||||
controller.observeSelfCorrect(OutcomeReported)
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("a failing verification outcome must escalate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnUncertainCompletion(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnCompletionUncertain: 2}})
|
||||
controller.observeUncertain()
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("first uncertain evaluation must not trip a threshold of 2")
|
||||
}
|
||||
controller.observeUncertain()
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("second uncertain evaluation must escalate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesAtMostOnce(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 1}})
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("expected first escalation")
|
||||
}
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 5}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
controller.observeUncertain()
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("escalation must be one-shot per run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostureEscalationOnUncertainCompletion proves the uncertain-path act
|
||||
// point: the completion gate's continue branch escapes the end-of-turn tail,
|
||||
// so escalation must apply before the continue. A cue turn under the gate
|
||||
// counts as uncertain, escalates, and the very next request carries the target
|
||||
// effort.
|
||||
func TestPostureEscalationOnUncertainCompletion(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
recorder := trace.NewRecorder("posture-session", "run-2", "fast")
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
Trace: recorder,
|
||||
Profile: &ProfilePolicy{
|
||||
Name: "fast",
|
||||
Escalate: &PostureEscalation{
|
||||
ReasoningEffort: "high",
|
||||
OnCompletionUncertain: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "Done. All set." {
|
||||
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
|
||||
}
|
||||
if provider.requests[1].ReasoningEffort != "high" {
|
||||
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[1].ReasoningEffort)
|
||||
}
|
||||
tr := recorder.Finish()
|
||||
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
|
||||
t.Fatalf("posture_escalations = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile that filled the run's effort displaced the provider default ("");
|
||||
// RestoreDefaultEffort lets escalation restore that default, which a plain ""
|
||||
// ReasoningEffort target cannot express (it means "leave untouched").
|
||||
func TestPostureEscalationRestoresDefaultEffort(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
ReasoningEffort: "low",
|
||||
RequireCompletionSignal: true,
|
||||
Profile: &ProfilePolicy{
|
||||
Name: "fast",
|
||||
Escalate: &PostureEscalation{
|
||||
RestoreDefaultEffort: true,
|
||||
OnCompletionUncertain: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "Done. All set." {
|
||||
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
|
||||
}
|
||||
if provider.requests[0].ReasoningEffort != "low" {
|
||||
t.Fatalf("pre-escalation request effort = %q, want the profile's low", provider.requests[0].ReasoningEffort)
|
||||
}
|
||||
if provider.requests[1].ReasoningEffort != "" {
|
||||
t.Fatalf("post-escalation request effort = %q, want the restored provider default (empty)", provider.requests[1].ReasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
// failingProfileTool always errors with the same signature so the repeated-
|
||||
// failure guard builds a streak.
|
||||
type failingProfileTool struct{}
|
||||
|
||||
func (failingProfileTool) Name() string { return "flaky_probe" }
|
||||
func (failingProfileTool) Description() string { return "always fails identically (test)" }
|
||||
func (failingProfileTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (failingProfileTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (failingProfileTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusError, Output: "probe exploded: same signature"}
|
||||
}
|
||||
|
||||
// TestPostureEscalationRaisesTurnCeilingMidRun proves the end-to-end act path:
|
||||
// a Fast-style run with a 2-turn ceiling hits a failure streak, escalates, and
|
||||
// finishes on a turn the original ceiling would have cut off, with the effort
|
||||
// target applied to the post-escalation request and the counter emitted.
|
||||
func TestPostureEscalationRaisesTurnCeilingMidRun(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(failingProfileTool{})
|
||||
|
||||
toolTurn := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn,
|
||||
toolTurn,
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "recovered after escalation"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
recorder := trace.NewRecorder("posture-session", "run-1", "fast")
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Trace: recorder,
|
||||
Profile: &ProfilePolicy{
|
||||
Name: "fast",
|
||||
Escalate: &PostureEscalation{
|
||||
MaxTurns: 4,
|
||||
ReasoningEffort: "high",
|
||||
OnToolFailureStreak: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "recovered after escalation" {
|
||||
t.Fatalf("expected the run to continue past the original 2-turn ceiling, got %q (turns=%d)", result.FinalAnswer, result.Turns)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("expected 3 requests (ceiling raised from 2 to 4), got %d", len(provider.requests))
|
||||
}
|
||||
if provider.requests[2].ReasoningEffort != "high" {
|
||||
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[2].ReasoningEffort)
|
||||
}
|
||||
tr := recorder.Finish()
|
||||
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
|
||||
t.Fatalf("posture_escalations = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostureEscalationAbsentWithoutProfile pins the no-regression invariant at
|
||||
// the loop level: the identical failing script without a profile ends at the
|
||||
// original ceiling with the max-turns answer.
|
||||
func TestPostureEscalationAbsentWithoutProfile(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(failingProfileTool{})
|
||||
|
||||
toolTurn := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{toolTurn, toolTurn}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) > 3 {
|
||||
t.Fatalf("unprofiled run must not extend the ceiling, got %d requests", len(provider.requests))
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, "maximum number of turns") {
|
||||
t.Fatalf("unprofiled run must end with the max-turns answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteToolCallClassifiesRiskWithoutSandbox verifies the executed-risk
|
||||
// stamp on the unsandboxed path: a read-only tool classifies low.
|
||||
func TestExecuteToolCallClassifiesRiskWithoutSandbox(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(failingProfileTool{})
|
||||
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "flaky_probe"},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatal(abortErr)
|
||||
}
|
||||
if result.Risk.Level == "" {
|
||||
t.Fatal("executed result must carry a risk classification")
|
||||
}
|
||||
if riskRank(result.Risk.Level) > riskRank(sandbox.RiskMedium) {
|
||||
t.Fatalf("read-only tool classified %q, want at most medium", result.Risk.Level)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownToolResultCarriesZeroRisk verifies a call that never executed a
|
||||
// tool (unknown name, nil tool) keeps the zero risk value instead of panicking
|
||||
// or inventing a classification.
|
||||
func TestUnknownToolResultCarriesZeroRisk(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "no_such_tool"},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatal(abortErr)
|
||||
}
|
||||
if result.Status == tools.StatusOK {
|
||||
t.Fatal("expected an error result for an unknown tool")
|
||||
}
|
||||
if result.Risk.Level != "" {
|
||||
t.Fatalf("not-executed result must keep the zero risk value, got %q", result.Risk.Level)
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,11 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// promptSubstrings are the seven cacheable sub-components of the prompt a run
|
||||
// sends to a model. Each is the literal content the sub-component would emit
|
||||
// into the system prompt or tool list, hashed so a downstream observer can
|
||||
// detect drift turn-over-turn. The substrings are produced by
|
||||
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
|
||||
// promptSubstrings are the cacheable components of the prompt a run sends to a
|
||||
// model. systemPrompt is the exact joined system-message content. The remaining
|
||||
// fields retain useful diagnostic boundaries within that prompt and tool list.
|
||||
type promptSubstrings struct {
|
||||
systemPrompt string
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
@@ -25,92 +24,55 @@ type promptSubstrings struct {
|
||||
schema string
|
||||
}
|
||||
|
||||
// buildPromptSubstrings assembles the seven cacheable sub-components of the
|
||||
// prompt without joining them. The system prompt sections are produced by the
|
||||
// same private builders that buildSystemPrompt uses, so the substrings are
|
||||
// byte-identical to what would have appeared in the joined prompt. The tools
|
||||
// and schema substrings are derived from the partitioned tool list (caller-
|
||||
// provided) so this helper does not need to re-run the partition.
|
||||
//
|
||||
// This is the seam ComputePrefixFingerprint reads from. It exists as a
|
||||
// separate function (rather than returning both prompt and substrings from
|
||||
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
|
||||
// and the substrings helper is independently testable.
|
||||
// buildPromptSubstrings builds the prompt and retains both its exact joined
|
||||
// form and narrower diagnostic components. Callers that already built the
|
||||
// prompt should use buildPromptSubstringsFromParts to avoid repeating workspace
|
||||
// reads.
|
||||
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
core := strings.TrimSpace(options.SystemPrompt)
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(coreSystemPrompt)
|
||||
}
|
||||
if core == "" {
|
||||
core = fallbackSystemPrompt
|
||||
}
|
||||
return buildPromptSubstringsFromParts(buildSystemPromptParts(options), exposed)
|
||||
}
|
||||
|
||||
// The confirmation policy is appended unconditionally in buildSystemPrompt;
|
||||
// the substring here is the same constant.
|
||||
policy := strings.TrimSpace(confirmationPolicy)
|
||||
|
||||
// projectContext is the joined output of workspaceContext, which itself
|
||||
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
|
||||
// joined string captures every guideline file's contribution and the
|
||||
// ordering between them.
|
||||
project := workspaceContext(options.Cwd)
|
||||
|
||||
// skills is the joined output of skillsContext, with each skill on its own
|
||||
// line. The substring is the literal text the model would see.
|
||||
skills := skillsContext(options)
|
||||
|
||||
// tools and schema are derived from the partitioned tool list. The list
|
||||
// passed in is already in stable order (partitionToolsCached is the
|
||||
// canonical stable partitioner) so concatenating in slice order is
|
||||
// sufficient — we sort names again defensively in case a future caller
|
||||
// hands us a list from a different partitioner.
|
||||
func buildPromptSubstringsFromParts(parts systemPromptParts, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
||||
|
||||
return promptSubstrings{
|
||||
baseInstructions: core,
|
||||
confirmationPolicy: policy,
|
||||
projectContext: project,
|
||||
skills: skills,
|
||||
systemPrompt: parts.prompt,
|
||||
baseInstructions: parts.baseInstructions,
|
||||
confirmationPolicy: parts.confirmationPolicy,
|
||||
projectContext: parts.projectContext,
|
||||
skills: parts.skills,
|
||||
tools: toolsSubstr,
|
||||
schema: schemaSubstr,
|
||||
}
|
||||
}
|
||||
|
||||
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
|
||||
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
|
||||
// the partition produces a different hash. The schema substring is the
|
||||
// concatenation of each tool's JSON schema in name order, so a schema change
|
||||
// is detected independently of the tool ordering.
|
||||
// toolSubstrings preserves the emitted tool order. Names and descriptions are
|
||||
// fingerprinted separately from schemas so either kind of drift is identifiable.
|
||||
// Order is part of request identity: silently sorting here would hide a provider-
|
||||
// visible reordering and could report a false cache hit.
|
||||
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
||||
if len(exposed) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
names := make([]string, 0, len(exposed))
|
||||
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
|
||||
for _, def := range exposed {
|
||||
names = append(names, def.Name)
|
||||
byName[def.Name] = def
|
||||
}
|
||||
sort.Strings(names)
|
||||
var toolsSB, schemaSB strings.Builder
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
toolsSB.WriteByte('\n')
|
||||
}
|
||||
toolsSB.WriteString(name)
|
||||
def := byName[name]
|
||||
for _, def := range exposed {
|
||||
writeLengthPrefixed(&toolsSB, def.Name)
|
||||
writeLengthPrefixed(&toolsSB, def.Description)
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
schemaSB.WriteString(name)
|
||||
schemaSB.WriteByte('\n')
|
||||
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
|
||||
schemaSB.WriteByte('\n')
|
||||
writeLengthPrefixed(&schemaSB, def.Name)
|
||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
func writeLengthPrefixed(sb *strings.Builder, value string) {
|
||||
fmt.Fprintf(sb, "%d:", len(value))
|
||||
sb.WriteString(value)
|
||||
}
|
||||
|
||||
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
||||
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
||||
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
||||
@@ -227,36 +189,16 @@ func writeStable(sb *strings.Builder, v any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
|
||||
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
|
||||
// independent SHA-256s of the corresponding sub-component; the complete-prefix
|
||||
// hash is a SHA-256 of the canonical concatenation of the other six, so any
|
||||
// sub-component drift is observable both individually and in aggregate.
|
||||
//
|
||||
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
|
||||
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
|
||||
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
|
||||
// context work is performed separately by buildSystemPrompt a few lines
|
||||
// later in the request-build path, so on every traced turn the file and
|
||||
// git reads happen twice. The duplication is intentional for this PR (a
|
||||
// follow-up will pass substrings through) and is a known perf cost only on
|
||||
// the opt-in trace path.
|
||||
//
|
||||
// A stable CompletePrefixHash across turns means the four captured
|
||||
// sub-components (baseInstructions, confirmationPolicy, projectContext,
|
||||
// skills) are byte-identical. It does NOT rule out drift in the seven
|
||||
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
|
||||
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
|
||||
// userGuidelines, specialistDelegationContext, responseStyleContext). For
|
||||
// default Options the four captured substrings are the full prompt; for
|
||||
// non-default Options the seven uncaptured sections may contribute, and a
|
||||
// consumer correlating CompletePrefix stability with cached_input_tokens
|
||||
// must cross-check the model_switches counter (modelPromptAddendum changes
|
||||
// on a model switch) to disambiguate "no drift" from "drift in an
|
||||
// uncaptured section."
|
||||
|
||||
// ComputePrefixFingerprint builds and fingerprints a prompt for callers that do
|
||||
// not already have one. The agent loop uses computePrefixFingerprint with the
|
||||
// prompt parts it built once at run start, ensuring tracing observes the exact
|
||||
// request content without repeating workspace I/O.
|
||||
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
||||
subs := buildPromptSubstrings(options, exposed)
|
||||
return computePrefixFingerprint(buildPromptSubstrings(options, exposed))
|
||||
}
|
||||
|
||||
func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
||||
systemPrompt := sha256hex(subs.systemPrompt)
|
||||
base := sha256hex(subs.baseInstructions)
|
||||
policy := sha256hex(subs.confirmationPolicy)
|
||||
project := sha256hex(subs.projectContext)
|
||||
@@ -264,9 +206,10 @@ func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinit
|
||||
toolsH := sha256hex(subs.tools)
|
||||
schema := sha256hex(subs.schema)
|
||||
complete := sha256hex(strings.Join([]string{
|
||||
base, policy, project, skills, toolsH, schema,
|
||||
systemPrompt, toolsH, schema,
|
||||
}, "|"))
|
||||
return prefixFingerprint{
|
||||
SystemPromptHash: systemPrompt,
|
||||
BaseInstructionsHash: base,
|
||||
ConfirmationPolicyHash: policy,
|
||||
ProjectContextHash: project,
|
||||
@@ -282,6 +225,7 @@ func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinit
|
||||
// in loop.go) so the trace package does not need to import this one. The field
|
||||
// names match the trace.PrefixHash JSON tags 1:1.
|
||||
type prefixFingerprint struct {
|
||||
SystemPromptHash string
|
||||
BaseInstructionsHash string
|
||||
ConfirmationPolicyHash string
|
||||
ProjectContextHash string
|
||||
|
||||
@@ -28,38 +28,28 @@ func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
|
||||
// name-set sensitivity of the ToolsHash. The test name used to read as
|
||||
// "reorder changes hash" but the assertion is the opposite: the hash is
|
||||
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
|
||||
// names before hashing). The partitioner is expected to produce a stable
|
||||
// order, so a name-order change is not a hash signal — a name-set change
|
||||
// is. The test asserts both: permutation of the same tools produces the
|
||||
// same hash, and adding a tool produces a different hash.
|
||||
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
|
||||
// Tool order and descriptions are provider-visible and therefore part of the
|
||||
// cacheable prefix identity.
|
||||
func TestComputePrefixFingerprintToolsHashMatchesEmittedOrderAndDescriptions(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
a := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
}
|
||||
b := []zeroruntime.ToolDefinition{
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
}
|
||||
// toolSubstrings sorts names internally, so the ToolsHash is identical
|
||||
// for a permutation of the same tools. We assert that here to document
|
||||
// the contract: name-order independence, name-set sensitivity.
|
||||
fpA := ComputePrefixFingerprint(opts, a)
|
||||
fpB := ComputePrefixFingerprint(opts, b)
|
||||
if fpA.ToolsHash != fpB.ToolsHash {
|
||||
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||
if fpA.ToolsHash == fpB.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when emitted order changes: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||
}
|
||||
// A different tool set must produce a different ToolsHash.
|
||||
c := append([]zeroruntime.ToolDefinition{}, a...)
|
||||
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
|
||||
c := append([]zeroruntime.ToolDefinition(nil), a...)
|
||||
c[0].Description = "read a file"
|
||||
fpC := ComputePrefixFingerprint(opts, c)
|
||||
if fpA.ToolsHash == fpC.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||
t.Fatalf("ToolsHash must change when a description changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,25 +91,35 @@ func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
|
||||
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
|
||||
// This is the regression catch for "did anyone reorder or drop a sub-hash
|
||||
// from the canonical join without updating CompletePrefixHash."
|
||||
// CompletePrefixHash represents the exact joined system prompt and the ordered
|
||||
// tool definitions. Narrower system-section hashes remain diagnostic only.
|
||||
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
||||
fp := ComputePrefixFingerprint(opts, exposed)
|
||||
// Manually compute what the canonical join should be.
|
||||
expected := sha256hex(strings.Join([]string{
|
||||
fp.BaseInstructionsHash,
|
||||
fp.ConfirmationPolicyHash,
|
||||
fp.ProjectContextHash,
|
||||
fp.SkillsHash,
|
||||
fp.SystemPromptHash,
|
||||
fp.ToolsHash,
|
||||
fp.SchemaHash,
|
||||
}, "|"))
|
||||
if fp.CompletePrefixHash != expected {
|
||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the exact system, tools, and schema hashes:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
|
||||
base := Options{Cwd: t.TempDir(), SystemPrompt: "core", Model: "gpt-5"}
|
||||
changed := base
|
||||
changed.ResponseStyle = "concise"
|
||||
|
||||
fpBase := ComputePrefixFingerprint(base, nil)
|
||||
fpChanged := ComputePrefixFingerprint(changed, nil)
|
||||
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
||||
t.Fatal("SystemPromptHash must change when any rendered system section changes")
|
||||
}
|
||||
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
||||
t.Fatal("CompletePrefixHash must include the exact full system prompt")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,9 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
||||
if subs.baseInstructions != "test core" {
|
||||
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
||||
}
|
||||
if subs.systemPrompt != buildSystemPrompt(opts) {
|
||||
t.Fatal("systemPrompt substring must equal the exact joined system prompt")
|
||||
}
|
||||
if subs.confirmationPolicy == "" {
|
||||
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
||||
}
|
||||
@@ -151,6 +154,9 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
||||
// sha256hex of the substrings, with no truncation or reformatting
|
||||
// between the two layers.
|
||||
fp := ComputePrefixFingerprint(opts, nil)
|
||||
if fp.SystemPromptHash != sha256hex(subs.systemPrompt) {
|
||||
t.Fatalf("SystemPromptHash in fingerprint must equal sha256hex of the joined prompt")
|
||||
}
|
||||
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
||||
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
||||
}
|
||||
|
||||
@@ -70,6 +70,22 @@ const (
|
||||
// guidelines), and the safety confirmation policy. It is built once per run so
|
||||
// every turn shares one (cacheable) system turn.
|
||||
func buildSystemPrompt(options Options) string {
|
||||
return buildSystemPromptParts(options).prompt
|
||||
}
|
||||
|
||||
// systemPromptParts retains both the exact joined prompt and the diagnostic
|
||||
// sections used by prompt-prefix tracing. Building these together prevents the
|
||||
// tracing path from re-reading workspace state and guarantees that the hash is
|
||||
// computed from the same prompt bytes placed in the request.
|
||||
type systemPromptParts struct {
|
||||
prompt string
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
skills string
|
||||
}
|
||||
|
||||
func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
core := strings.TrimSpace(options.SystemPrompt)
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(coreSystemPrompt)
|
||||
@@ -97,22 +113,31 @@ func buildSystemPrompt(options Options) string {
|
||||
if user := userGuidelines(); user != "" {
|
||||
sections = append(sections, user)
|
||||
}
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
project := workspaceContext(options.Cwd)
|
||||
if project != "" {
|
||||
sections = append(sections, project)
|
||||
}
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||
skillsBlock := skillsContext(options)
|
||||
if skillsBlock != "" {
|
||||
sections = append(sections, skillsBlock)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
|
||||
policy := strings.TrimSpace(confirmationPolicy)
|
||||
if policy != "" {
|
||||
sections = append(sections, policy)
|
||||
}
|
||||
return strings.Join(sections, "\n\n")
|
||||
return systemPromptParts{
|
||||
prompt: strings.Join(sections, "\n\n"),
|
||||
baseInstructions: core,
|
||||
confirmationPolicy: policy,
|
||||
projectContext: project,
|
||||
skills: skillsBlock,
|
||||
}
|
||||
}
|
||||
|
||||
// responseStyleContext renders the operator-selected reply style (TUI /style) as
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type taskStatus string
|
||||
|
||||
const (
|
||||
taskStatusActive taskStatus = "active"
|
||||
taskStatusComplete taskStatus = "complete"
|
||||
taskStatusIncomplete taskStatus = "incomplete"
|
||||
)
|
||||
|
||||
type taskPlanParity string
|
||||
|
||||
const (
|
||||
taskPlanParityUnknown taskPlanParity = "unknown"
|
||||
taskPlanParityMatch taskPlanParity = "match"
|
||||
taskPlanParityMismatch taskPlanParity = "mismatch"
|
||||
)
|
||||
|
||||
type taskPlanItem struct {
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type taskPlanState struct {
|
||||
Items []taskPlanItem `json:"items,omitempty"`
|
||||
Pending int `json:"pending"`
|
||||
InProgress int `json:"in_progress"`
|
||||
Completed int `json:"completed"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type taskToolState struct {
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type taskVerificationState struct {
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
LastOutcome Outcome `json:"last_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type taskStateSnapshot struct {
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
}
|
||||
|
||||
type taskStateEventKind int
|
||||
|
||||
const (
|
||||
taskStateEventPlan taskStateEventKind = iota + 1
|
||||
taskStateEventToolResult
|
||||
taskStateEventVerification
|
||||
taskStateEventCompletion
|
||||
)
|
||||
|
||||
type taskStateEvent struct {
|
||||
kind taskStateEventKind
|
||||
arguments string
|
||||
toolResult ToolResult
|
||||
verification Outcome
|
||||
completion completionEvaluation
|
||||
}
|
||||
|
||||
// taskState is a deterministic projection of facts the loop already observes.
|
||||
// It does not initiate work or replace the transcript; consumers must check
|
||||
// transcript parity before using its compact representation.
|
||||
type taskState struct {
|
||||
snapshotValue taskStateSnapshot
|
||||
changedFiles map[string]struct{}
|
||||
recorder *trace.Recorder
|
||||
planObserved bool
|
||||
}
|
||||
|
||||
func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||
state := &taskState{
|
||||
snapshotValue: taskStateSnapshot{
|
||||
Objective: strings.TrimSpace(objective),
|
||||
Status: taskStatusActive,
|
||||
PlanParity: taskPlanParityUnknown,
|
||||
},
|
||||
changedFiles: map[string]struct{}{},
|
||||
}
|
||||
state.recorder = recorder
|
||||
return state
|
||||
}
|
||||
|
||||
func (state *taskState) observe(event taskStateEvent) bool {
|
||||
if state == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
switch event.kind {
|
||||
case taskStateEventPlan:
|
||||
plan, ok := parseTaskPlan(event.arguments)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
state.snapshotValue.Plan = summarizeTaskPlan(plan)
|
||||
state.planObserved = true
|
||||
state.markActive()
|
||||
changed = true
|
||||
case taskStateEventToolResult:
|
||||
state.markActive()
|
||||
if event.toolResult.Status == tools.StatusOK {
|
||||
state.snapshotValue.Tools.Succeeded++
|
||||
} else {
|
||||
state.snapshotValue.Tools.Failed++
|
||||
}
|
||||
for _, path := range event.toolResult.ChangedFiles {
|
||||
path = strings.TrimSpace(path)
|
||||
if path != "" {
|
||||
state.changedFiles[path] = struct{}{}
|
||||
}
|
||||
}
|
||||
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
||||
changed = true
|
||||
case taskStateEventVerification:
|
||||
if event.verification == OutcomeDisabled {
|
||||
return false
|
||||
}
|
||||
state.markActive()
|
||||
state.snapshotValue.Verification.LastOutcome = event.verification
|
||||
switch event.verification {
|
||||
case OutcomePassed:
|
||||
state.snapshotValue.Verification.Passed++
|
||||
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
|
||||
state.snapshotValue.Verification.Failed++
|
||||
}
|
||||
changed = true
|
||||
case taskStateEventCompletion:
|
||||
state.snapshotValue.CompletionDecision = event.completion.Decision
|
||||
state.snapshotValue.CompletionReason = event.completion.Reason
|
||||
switch event.completion.Decision {
|
||||
case CompletionComplete:
|
||||
state.snapshotValue.Status = taskStatusComplete
|
||||
case CompletionIncomplete:
|
||||
state.snapshotValue.Status = taskStatusIncomplete
|
||||
default:
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
state.snapshotValue.Revision++
|
||||
if event.kind != taskStateEventToolResult {
|
||||
state.emit()
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (state *taskState) markActive() {
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
state.snapshotValue.CompletionDecision = ""
|
||||
state.snapshotValue.CompletionReason = ""
|
||||
}
|
||||
|
||||
func (state *taskState) snapshot() taskStateSnapshot {
|
||||
if state == nil {
|
||||
return taskStateSnapshot{}
|
||||
}
|
||||
snapshot := state.snapshotValue
|
||||
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
|
||||
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// observePlanParity compares only the latest plan projection with the plan tool
|
||||
// calls still present in messages. It mutates the snapshot and emits when the
|
||||
// parity value changes; objective, tool, and verification fields are not part of
|
||||
// this comparison.
|
||||
func (state *taskState) observePlanParity(messages []zeroruntime.Message) taskPlanParity {
|
||||
if state == nil {
|
||||
return taskPlanParityUnknown
|
||||
}
|
||||
transcriptPlan, found, valid := latestTaskPlan(messages)
|
||||
tracked := state.snapshotValue.Plan.Items
|
||||
parity := taskPlanParityMatch
|
||||
if !valid || found != state.planObserved || (found && !reflect.DeepEqual(transcriptPlan, tracked)) {
|
||||
parity = taskPlanParityMismatch
|
||||
}
|
||||
if state.snapshotValue.PlanParity != parity {
|
||||
state.snapshotValue.PlanParity = parity
|
||||
state.snapshotValue.Revision++
|
||||
state.emit()
|
||||
}
|
||||
return parity
|
||||
}
|
||||
|
||||
type completionContext struct {
|
||||
Objective string
|
||||
PlanPending bool
|
||||
PlanMatchesTranscript bool
|
||||
}
|
||||
|
||||
func (state *taskState) completionContext(messages []zeroruntime.Message, transcriptPlanPending bool) completionContext {
|
||||
context := completionContext{PlanPending: transcriptPlanPending}
|
||||
if state == nil {
|
||||
return context
|
||||
}
|
||||
context.Objective = state.snapshotValue.Objective
|
||||
context.PlanMatchesTranscript = state.observePlanParity(messages) == taskPlanParityMatch
|
||||
if context.PlanMatchesTranscript {
|
||||
context.PlanPending = state.snapshotValue.Plan.Pending+state.snapshotValue.Plan.InProgress > 0
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func (state *taskState) snapshotForCompaction(messages []zeroruntime.Message) *taskStateSnapshot {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
state.observePlanParity(messages)
|
||||
snapshot := state.snapshot()
|
||||
return &snapshot
|
||||
}
|
||||
|
||||
func (state *taskState) emit() {
|
||||
if state == nil || state.recorder == nil {
|
||||
return
|
||||
}
|
||||
snapshot := state.snapshotValue
|
||||
state.recorder.EmitTaskState(trace.TaskStateEvent{
|
||||
Revision: snapshot.Revision,
|
||||
Status: string(snapshot.Status),
|
||||
PlanPending: snapshot.Plan.Pending,
|
||||
PlanInProgress: snapshot.Plan.InProgress,
|
||||
PlanCompleted: snapshot.Plan.Completed,
|
||||
PlanFailed: snapshot.Plan.Failed,
|
||||
ToolsSucceeded: snapshot.Tools.Succeeded,
|
||||
ToolsFailed: snapshot.Tools.Failed,
|
||||
VerificationPassed: snapshot.Verification.Passed,
|
||||
VerificationFailed: snapshot.Verification.Failed,
|
||||
VerificationOutcome: string(snapshot.Verification.LastOutcome),
|
||||
ChangedFileCount: len(snapshot.ChangedFiles),
|
||||
CompletionDecision: string(snapshot.CompletionDecision),
|
||||
PlanParity: string(snapshot.PlanParity),
|
||||
})
|
||||
}
|
||||
|
||||
func parseTaskPlan(arguments string) ([]taskPlanItem, bool) {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
||||
return nil, false
|
||||
}
|
||||
rawPlan, ok := object["plan"]
|
||||
if !ok || string(rawPlan) == "null" {
|
||||
return nil, false
|
||||
}
|
||||
var parsed []struct {
|
||||
ID *string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if json.Unmarshal(rawPlan, &parsed) != nil {
|
||||
return nil, false
|
||||
}
|
||||
plan := make([]taskPlanItem, 0, len(parsed))
|
||||
for _, raw := range parsed {
|
||||
if raw.Content == "" {
|
||||
return nil, false
|
||||
}
|
||||
plan = append(plan, taskPlanItem{
|
||||
Content: raw.Content,
|
||||
Status: normalizeTaskPlanStatus(raw.Status),
|
||||
Notes: raw.Notes,
|
||||
})
|
||||
}
|
||||
lastInProgress := -1
|
||||
for index := range plan {
|
||||
if plan[index].Status == "in_progress" {
|
||||
if lastInProgress >= 0 {
|
||||
plan[lastInProgress].Status = "completed"
|
||||
}
|
||||
lastInProgress = index
|
||||
}
|
||||
}
|
||||
if len(plan) == 0 {
|
||||
return nil, true
|
||||
}
|
||||
return plan, true
|
||||
}
|
||||
|
||||
func normalizeTaskPlanStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]":
|
||||
return "completed"
|
||||
case "in_progress", "in-progress", "inprogress", "in progress", "active", "doing", "started", "current", "wip", "ongoing", "running":
|
||||
return "in_progress"
|
||||
case "failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
return "failed"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeTaskPlan(items []taskPlanItem) taskPlanState {
|
||||
plan := taskPlanState{Items: append([]taskPlanItem(nil), items...)}
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case "completed":
|
||||
plan.Completed++
|
||||
case "in_progress":
|
||||
plan.InProgress++
|
||||
case "failed":
|
||||
plan.Failed++
|
||||
default:
|
||||
plan.Pending++
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func latestTaskPlan(messages []zeroruntime.Message) ([]taskPlanItem, bool, bool) {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
for j := len(messages[i].ToolCalls) - 1; j >= 0; j-- {
|
||||
call := messages[i].ToolCalls[j]
|
||||
if call.Name != planToolName {
|
||||
continue
|
||||
}
|
||||
plan, ok := parseTaskPlan(call.Arguments)
|
||||
return plan, true, ok
|
||||
}
|
||||
}
|
||||
return nil, false, true
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
keys = append(keys, value)
|
||||
}
|
||||
// Paths are evidence, not chronology. Stable ordering makes replay snapshots
|
||||
// deterministic even if events originate from parallel read batches later.
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestTaskStateReplayIsDeterministic(t *testing.T) {
|
||||
events := []taskStateEvent{
|
||||
{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"inspect","status":"done"},{"content":"implement","status":"in progress"},{"content":"verify"}]}`},
|
||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "apply_patch", Status: tools.StatusOK, ChangedFiles: []string{"a.go", "a.go", "b.go"}}},
|
||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "go test", Status: tools.StatusError}},
|
||||
{kind: taskStateEventVerification, verification: OutcomePassed},
|
||||
{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionUncertain, Reason: "pending work"}},
|
||||
}
|
||||
|
||||
first := newTaskState("ship the change", nil)
|
||||
second := newTaskState("ship the change", nil)
|
||||
for _, event := range events {
|
||||
first.observe(event)
|
||||
second.observe(event)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(first.snapshot(), second.snapshot()) {
|
||||
t.Fatalf("replaying the same events produced different snapshots:\nfirst: %#v\nsecond: %#v", first.snapshot(), second.snapshot())
|
||||
}
|
||||
got := first.snapshot()
|
||||
if got.Status != taskStatusActive || got.Plan.Pending != 1 || got.Plan.InProgress != 1 || got.Plan.Completed != 1 {
|
||||
t.Fatalf("unexpected plan snapshot: %#v", got)
|
||||
}
|
||||
if got.Tools.Succeeded != 1 || got.Tools.Failed != 1 || len(got.ChangedFiles) != 2 {
|
||||
t.Fatalf("unexpected evidence snapshot: %#v", got)
|
||||
}
|
||||
if got.Verification.Passed != 1 || got.Verification.Failed != 0 || got.Verification.LastOutcome != OutcomePassed {
|
||||
t.Fatalf("unexpected verification snapshot: %#v", got.Verification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateCoalescesToolResultsIntoNextTraceEvent(t *testing.T) {
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
state := newTaskState("objective", recorder)
|
||||
for range 20 {
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||
}
|
||||
if events := recorder.Finish().TaskStates; len(events) != 0 {
|
||||
t.Fatalf("tool results should be coalesced, got %d trace events", len(events))
|
||||
}
|
||||
|
||||
recorder = trace.NewRecorder("session", "run-2", "")
|
||||
state = newTaskState("objective", recorder)
|
||||
for range 20 {
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) != 1 || events[0].ToolsSucceeded != 20 {
|
||||
t.Fatalf("coalesced tool total missing from completion snapshot: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEmitsTaskStateFromExistingLoopEvents(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "plan-1", ToolName: planToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "plan-1", ArgumentsFragment: `{"plan":[{"content":"implement","status":"completed"}]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "plan-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
|
||||
result, err := Run(context.Background(), "implement the change", provider, Options{
|
||||
Registry: registry,
|
||||
Trace: recorder,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) < 3 {
|
||||
t.Fatalf("expected plan, tool, completion, and parity snapshots, got %#v", events)
|
||||
}
|
||||
last := events[len(events)-1]
|
||||
if last.Status != string(taskStatusComplete) || last.PlanCompleted != 1 || last.ToolsSucceeded != 1 || last.PlanParity != string(taskPlanParityMatch) {
|
||||
t.Fatalf("unexpected final task snapshot: %#v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"one","status":"pending"}]}`})
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK, ChangedFiles: []string{"one.go"}}})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
snapshot.Plan.Items[0].Content = "mutated"
|
||||
snapshot.ChangedFiles[0] = "mutated.go"
|
||||
|
||||
fresh := state.snapshot()
|
||||
if fresh.Plan.Items[0].Content != "one" || fresh.ChangedFiles[0] != "one.go" {
|
||||
t.Fatalf("snapshot mutation leaked into task state: %#v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`})
|
||||
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`},
|
||||
}}}
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("parity = %q, want match", parity)
|
||||
}
|
||||
|
||||
messages[0].ToolCalls[0].Arguments = `{"plan":[{"content":"different","status":"pending"}]}`
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMismatch {
|
||||
t.Fatalf("parity = %q, want mismatch", parity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateMatchesPlanToolNormalization(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
arguments := `{"plan":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: arguments})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
if snapshot.Plan.Completed != 1 || snapshot.Plan.InProgress != 1 {
|
||||
t.Fatalf("multiple active items were not normalized like the plan tool: %#v", snapshot.Plan)
|
||||
}
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: arguments}}}}
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("normalized state should still match its transcript event, got %q", parity)
|
||||
}
|
||||
|
||||
empty := newTaskState("objective", nil)
|
||||
empty.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[]}`})
|
||||
emptyMessages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[]}`}}}}
|
||||
if parity := empty.observePlanParity(emptyMessages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("explicit empty plan should match transcript, got %q", parity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateContextFallsBackWhenTranscriptDiffers(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"tracked","status":"completed"}]}`})
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"transcript","status":"pending"}]}`},
|
||||
}}}
|
||||
|
||||
context := state.completionContext(messages, true)
|
||||
if !context.PlanPending || context.PlanMatchesTranscript {
|
||||
t.Fatalf("completion context must retain transcript truth on mismatch: %#v", context)
|
||||
}
|
||||
if context.Objective != "objective" {
|
||||
t.Fatalf("objective lost from completion context: %#v", context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateCompactionSnapshotRetainsObjectiveOnPlanMismatch(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"verify","status":"pending"}]}`})
|
||||
matching := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"verify","status":"pending"}]}`},
|
||||
}}}
|
||||
if snapshot := state.snapshotForCompaction(matching); snapshot == nil || snapshot.Objective != "objective" {
|
||||
t.Fatalf("matching transcript should produce compact state, got %#v", snapshot)
|
||||
}
|
||||
|
||||
mismatching := append([]zeroruntime.Message(nil), matching...)
|
||||
mismatching[0].ToolCalls = []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[{"content":"other","status":"pending"}]}`}}
|
||||
if snapshot := state.snapshotForCompaction(mismatching); snapshot == nil || snapshot.Objective != "objective" || snapshot.PlanParity != taskPlanParityMismatch {
|
||||
t.Fatalf("plan mismatch must retain immutable objective and mark mutable state uncorroborated, got %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTaskPlanRejectsArgumentsTheToolWouldReject(t *testing.T) {
|
||||
for _, arguments := range []string{
|
||||
`{}`,
|
||||
`{"plan":null}`,
|
||||
`{"plan":[{"step":"alias is not accepted"}]}`,
|
||||
`{"plan":[{"content":""}]}`,
|
||||
`{"plan":[{"content":"valid"},{"content":""}]}`,
|
||||
`{"plan":[{"id":4,"content":"valid"}]}`,
|
||||
} {
|
||||
if plan, ok := parseTaskPlan(arguments); ok {
|
||||
t.Fatalf("parseTaskPlan(%s) = %#v, true; want rejected", arguments, plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ type ToolResult struct {
|
||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||
DenialReason DenialCategory
|
||||
// Risk is the sandbox risk classification of this call, stamped for EXECUTED
|
||||
// results so run-policy observers (the execution-profile controller) can see
|
||||
// the risk level of an allowed mutation. It mirrors the classification the
|
||||
// permission path already computes; denied or canceled results keep the zero
|
||||
// value. Pure observation: nothing about permissions or sandboxing changes.
|
||||
Risk sandbox.Risk
|
||||
// LoadedTools carries the deferred-tool names a tool_search call asked the
|
||||
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
|
||||
// ordinary tool result; only tool_search populates it.
|
||||
@@ -98,6 +104,54 @@ const (
|
||||
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
|
||||
)
|
||||
|
||||
// ProfilePolicy is the loop-facing slice of a selected execution profile.
|
||||
// The surface (exec flag, TUI command) resolves a named profile into this
|
||||
// policy; the loop only ever sees the policy, never the catalog.
|
||||
type ProfilePolicy struct {
|
||||
// Name labels trace counters and diagnostics (e.g. "fast"). The trace
|
||||
// recorder's profile label is set by the caller at recorder construction.
|
||||
Name string
|
||||
// Escalate, when non-nil, arms one-shot in-run posture escalation.
|
||||
Escalate *PostureEscalation
|
||||
}
|
||||
|
||||
// PostureEscalation describes a one-shot escalation to stricter knob values,
|
||||
// applied mid-run when an armed trigger fires. Targets are the values the
|
||||
// selected profile DISPLACED at run start (i.e. "restore the balanced
|
||||
// posture"), so escalation can never introduce a value that was not already
|
||||
// valid for this run and model. Zero-valued targets leave that knob untouched.
|
||||
type PostureEscalation struct {
|
||||
// MaxTurns raises the turn ceiling to this value when greater than the
|
||||
// ceiling in effect. 0 leaves the ceiling untouched.
|
||||
MaxTurns int
|
||||
// ReasoningEffort replaces the run's effort when non-empty.
|
||||
ReasoningEffort string
|
||||
// RestoreDefaultEffort clears the run's effort override (back to the
|
||||
// provider/model default) when true. It exists because the displaced value
|
||||
// of a profile-filled effort is "" — which as a ReasoningEffort target
|
||||
// means "leave untouched" — so restoring the default needs its own signal.
|
||||
// Ignored when ReasoningEffort is non-empty.
|
||||
RestoreDefaultEffort bool
|
||||
// RestoreCompletionGate re-enables RequireCompletionSignal (headless
|
||||
// completion semantics) when true.
|
||||
RestoreCompletionGate bool
|
||||
|
||||
// Triggers. A zero value disables that signal entirely.
|
||||
// OnToolFailureStreak fires when the repeated-failure guard observes a
|
||||
// same-tool retriable-failure streak of at least this length.
|
||||
OnToolFailureStreak int
|
||||
// OnCompletionUncertain fires on the Nth uncertain completion evaluation
|
||||
// (continue nudge or semantic check). Headless only: the completion gate
|
||||
// never runs interactively.
|
||||
OnCompletionUncertain int
|
||||
// OnSelfCorrectFailure fires when a post-edit verification cycle reports a
|
||||
// failing outcome (correcting, reported, or aborted).
|
||||
OnSelfCorrectFailure bool
|
||||
// OnRiskyMutation fires when an EXECUTED tool result carries a sandbox risk
|
||||
// level at or above this threshold. Empty disables the signal.
|
||||
OnRiskyMutation sandbox.RiskLevel
|
||||
}
|
||||
|
||||
type PermissionRequest struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
@@ -298,6 +352,12 @@ type Options struct {
|
||||
// contract as ModelSwitcher: a returned error records a note and the run
|
||||
// continues on the current model and session.
|
||||
ModelSessionSwitcher func(ctx context.Context, modelID string) (zeroruntime.TurnSessionProvider, error)
|
||||
// Profile, when set, arms the execution-profile posture controller for this
|
||||
// run (auto-escalation to stricter knob values on failure/uncertainty/risky
|
||||
// mutation signals). nil — the default everywhere today — leaves the loop
|
||||
// byte-identical: no observation, no escalation, no counters. Same opt-in
|
||||
// convention as Trace and SelfCorrect.
|
||||
Profile *ProfilePolicy
|
||||
// Trace, when set, records per-turn timing for the run: the loop stamps
|
||||
// spans (prompt build, generation, tool execution, permission wait,
|
||||
// compaction, provider connect) and counters (model requests, tool calls,
|
||||
|
||||
@@ -1341,6 +1341,9 @@ Flags:
|
||||
--spec-reasoning-effort <effort>
|
||||
Override draft reasoning effort when --use-spec is set
|
||||
--max-turns <number> Override the maximum agent loop turns
|
||||
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
|
||||
posture only (turn budget, effort, self-correction, escalation);
|
||||
composes with --mode (which picks the model) and explicit flags win
|
||||
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
|
||||
--enabled-tools <tools> Only expose these comma or space separated tools
|
||||
--disabled-tools <tools> Hide these comma or space separated tools
|
||||
|
||||
+89
-2
@@ -13,6 +13,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/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -69,7 +70,14 @@ type execOptions struct {
|
||||
// intentionally inert: nothing consumes it. Model selection is driven by
|
||||
// --model / --mode instead. See writeExecHelp ("Accept legacy model profile
|
||||
// selection") and TestRunExecAcceptsLegacyModelProfileFlags.
|
||||
modelProfile string
|
||||
modelProfile string
|
||||
// execProfile selects a named execution profile (balanced, fast, thorough):
|
||||
// a loop-posture bundle (turn budget, reasoning effort, self-correction,
|
||||
// escalation triggers) applied AFTER --mode with the same
|
||||
// fill-only-if-unset rule, so precedence is explicit flag > mode > profile.
|
||||
// Distinct from the mode preset also named "fast" (which picks a model) and
|
||||
// from the legacy inert --profile above. See internal/execprofile.
|
||||
execProfile string
|
||||
reasoningEffort string
|
||||
useSpec bool
|
||||
specModel string
|
||||
@@ -167,6 +175,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if err := applyExecMode(&options); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
// A profile tunes loop posture the way a mode tunes model selection, and
|
||||
// runs after it so the mode's fills count as "set" and win. MaxTurns is
|
||||
// deferred to config resolution below, where the displaced resolved budget
|
||||
// is known and becomes the escalation restore target.
|
||||
execProfile, execProfileFilledEffort, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
@@ -272,6 +288,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
var displacedMaxTurns int
|
||||
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
if err := validateExecToolFilters(options, registry); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
@@ -486,6 +504,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
reasoningEffort: forwardEffort,
|
||||
specPermissionMode: permissionMode,
|
||||
notifier: notifier,
|
||||
// The profile displaced resolved.MaxTurns above, so the spec-draft
|
||||
// run arms the same escalation policy as the main run (in practice
|
||||
// only the failure-streak and risky-mutation triggers can fire
|
||||
// here: the draft runs without the completion gate or a
|
||||
// self-corrector).
|
||||
profilePolicy: execProfile.Policy(displacedMaxTurns,
|
||||
specProfileEffortFilled(execProfileFilledEffort, options.specReasoningEffort)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -528,7 +553,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
var traceRecorder *trace.Recorder
|
||||
var traceSnapshot *trace.TurnTrace
|
||||
if tracePath != "" {
|
||||
traceRecorder = trace.NewRecorder(preparedSession.Session.SessionID, runID, "")
|
||||
traceRecorder = trace.NewRecorder(preparedSession.Session.SessionID, runID, execProfile.Name)
|
||||
defer func() {
|
||||
if err := writeTraceSnapshot(traceSnapshot, tracePath, stderr); err != nil {
|
||||
fmt.Fprintf(stderr, "[zero] failed to write trace: %s\n", err)
|
||||
@@ -627,6 +652,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
@@ -1133,6 +1159,67 @@ func applyExecMode(options *execOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyExecProfile expands an --exec-profile selection onto the exec options.
|
||||
// Like applyExecMode it only fills fields the caller left unset, and it runs
|
||||
// AFTER the mode so a mode-filled field counts as set: precedence is explicit
|
||||
// flag > --mode > --exec-profile. Only the options-level knobs are applied here
|
||||
// (reasoning effort still flows through the model-supported gating downstream;
|
||||
// self-correct is presence-only so a profile can only turn it on). MaxTurns is
|
||||
// intentionally NOT applied here — the profile displaces the resolved budget
|
||||
// after config resolution, where the displaced value is known and becomes the
|
||||
// escalation restore target. An unknown profile is a usage error listing the
|
||||
// valid names. Not to be confused with the legacy inert --profile flag.
|
||||
// The second return reports whether the profile actually filled the reasoning
|
||||
// effort (false when the user set one explicitly), so the escalation policy
|
||||
// knows the displaced effort was the provider default and can restore it.
|
||||
func applyExecProfile(options *execOptions) (execprofile.Profile, bool, error) {
|
||||
name := strings.TrimSpace(options.execProfile)
|
||||
if name == "" {
|
||||
return execprofile.Profile{}, false, nil
|
||||
}
|
||||
profile, ok := execprofile.Lookup(name)
|
||||
if !ok {
|
||||
return execprofile.Profile{}, false, execUsageError{fmt.Sprintf("unknown execution profile %q. Valid profiles: %s.", options.execProfile, strings.Join(execprofile.Names(), ", "))}
|
||||
}
|
||||
effortFilled := false
|
||||
if options.reasoningEffort == "" && profile.ReasoningEffort != "" {
|
||||
options.reasoningEffort = profile.ReasoningEffort
|
||||
effortFilled = true
|
||||
}
|
||||
// Spec-safe projection: the spec-draft path wires no self-corrector (an
|
||||
// explicit --self-correct --use-spec is rejected at parse time, before
|
||||
// profiles apply), so a profile's self-correct knob is meaningless there.
|
||||
// Project it away rather than erroring: the user asked for a thorough
|
||||
// DRAFT, and the profile's other knobs (budget, effort) apply to it fine.
|
||||
if profile.SelfCorrect && !options.useSpec {
|
||||
options.selfCorrect = true
|
||||
}
|
||||
return profile, effortFilled, nil
|
||||
}
|
||||
|
||||
// specProfileEffortFilled reports whether the profile's effort fill actually
|
||||
// governs the spec-draft run. An explicit --spec-reasoning-effort replaces the
|
||||
// filled effort for the draft, so the escalation's effort restore must not arm
|
||||
// there: escalation must never clear an effort the user pinned by hand.
|
||||
func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool {
|
||||
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
|
||||
}
|
||||
|
||||
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
|
||||
// The profile displaces the RESOLVED value, not the flag: a pinned budget
|
||||
// always wins and the profile backs off entirely, while an env/config budget
|
||||
// is the "balanced posture" a mid-run escalation can restore. pinnedMaxTurns
|
||||
// is any caller-pinned budget — an explicit --max-turns flag OR a mode
|
||||
// preset's fill (both land in options.maxTurns, and a mode outranks a profile
|
||||
// just like the flag does). displaced is 0 when nothing was displaced, so an
|
||||
// escalation leaves the ceiling untouched.
|
||||
func applyProfileTurnBudget(profile execprofile.Profile, pinnedMaxTurns int, resolvedMaxTurns int) (effective int, displaced int) {
|
||||
if profile.MaxTurns > 0 && pinnedMaxTurns == 0 {
|
||||
return profile.MaxTurns, resolvedMaxTurns
|
||||
}
|
||||
return resolvedMaxTurns, 0
|
||||
}
|
||||
|
||||
// resolveSelectedModel routes a user-supplied --model value through the model
|
||||
// registry so that fuzzy aliases (e.g. "sonnet 4.5") resolve to canonical ids
|
||||
// and deprecated models auto-redirect to their fallback. It returns the model id
|
||||
|
||||
@@ -147,6 +147,19 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
options.modelProfile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
case arg == "--exec-profile":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.execProfile = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--exec-profile="):
|
||||
value, err := requiredInlineFlagValue(arg, "--exec-profile")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.execProfile = value
|
||||
case arg == "-r" || arg == "--reasoning-effort":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
)
|
||||
|
||||
func TestApplyExecProfileFillsOnlyUnset(t *testing.T) {
|
||||
// Unset effort: the profile fills it and reports the fill, so the
|
||||
// escalation policy knows the displaced effort was the provider default.
|
||||
options := execOptions{execProfile: "fast"}
|
||||
profile, effortFilled, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if profile.Name != "fast" {
|
||||
t.Fatalf("profile.Name = %q, want fast", profile.Name)
|
||||
}
|
||||
if options.reasoningEffort != "low" {
|
||||
t.Fatalf("reasoningEffort = %q, want the profile's low", options.reasoningEffort)
|
||||
}
|
||||
if !effortFilled {
|
||||
t.Fatal("the profile filled the effort, so effortFilled must report it")
|
||||
}
|
||||
|
||||
// Set effort (explicit flag or a mode's fill): the profile backs off.
|
||||
options = execOptions{execProfile: "fast", reasoningEffort: "high"}
|
||||
_, effortFilled, err = applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, an explicit value must win over the profile", options.reasoningEffort)
|
||||
}
|
||||
if effortFilled {
|
||||
t.Fatal("the profile backed off, so effortFilled must be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecProfileThoroughArmsSelfCorrect(t *testing.T) {
|
||||
options := execOptions{execProfile: "thorough"}
|
||||
if _, _, err := applyExecProfile(&options); err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if !options.selfCorrect {
|
||||
t.Fatal("thorough must arm the post-edit self-correct loop")
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, want high", options.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
// The spec-draft path wires no self-corrector (explicit --self-correct
|
||||
// --use-spec is rejected at parse time, before profiles apply), so a profile's
|
||||
// self-correct knob is projected away under --use-spec instead of silently
|
||||
// arming a knob the draft runner ignores. The other knobs still apply.
|
||||
func TestApplyExecProfileSpecProjectsAwaySelfCorrect(t *testing.T) {
|
||||
options := execOptions{execProfile: "thorough", useSpec: true}
|
||||
if _, _, err := applyExecProfile(&options); err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if options.selfCorrect {
|
||||
t.Fatal("thorough under --use-spec must not arm self-correct (the draft runner has none)")
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, the profile's other knobs must still apply", options.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecProfileUnknownIsUsageError(t *testing.T) {
|
||||
options := execOptions{execProfile: "turbo"}
|
||||
_, _, err := applyExecProfile(&options)
|
||||
if err == nil {
|
||||
t.Fatal("expected a usage error for an unknown profile")
|
||||
}
|
||||
if _, ok := err.(execUsageError); !ok {
|
||||
t.Fatalf("expected execUsageError, got %T: %v", err, err)
|
||||
}
|
||||
for _, name := range execprofile.Names() {
|
||||
if !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("usage error must list %q, got %q", name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence: explicit flag > --mode > --exec-profile. The deep mode fills
|
||||
// effort=high and max-turns=160; the fast profile must not override either,
|
||||
// because a mode-filled field counts as set by the time the profile runs.
|
||||
func TestExecProfilePrecedenceFlagOverModeOverProfile(t *testing.T) {
|
||||
options := execOptions{mode: "deep", execProfile: "fast"}
|
||||
if err := applyExecMode(&options); err != nil {
|
||||
t.Fatalf("applyExecMode: %v", err)
|
||||
}
|
||||
profile, _, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, the mode's high must win over the profile's low", options.reasoningEffort)
|
||||
}
|
||||
// The mode filled options.maxTurns, so the turn budget must not be displaced.
|
||||
effective, displaced := applyProfileTurnBudget(profile, options.maxTurns, options.maxTurns)
|
||||
if effective != 160 || displaced != 0 {
|
||||
t.Fatalf("turn budget = (%d, displaced %d), the mode's 160 must win with nothing displaced", effective, displaced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProfileTurnBudget(t *testing.T) {
|
||||
fast, _ := execprofile.Lookup("fast")
|
||||
balanced, _ := execprofile.Lookup("balanced")
|
||||
|
||||
// No explicit flag: the profile displaces the resolved budget.
|
||||
if effective, displaced := applyProfileTurnBudget(fast, 0, 80); effective != 30 || displaced != 80 {
|
||||
t.Fatalf("fast over resolved 80 = (%d, %d), want (30, 80)", effective, displaced)
|
||||
}
|
||||
// Explicit --max-turns pins the budget; the profile backs off entirely.
|
||||
if effective, displaced := applyProfileTurnBudget(fast, 50, 50); effective != 50 || displaced != 0 {
|
||||
t.Fatalf("fast with explicit 50 = (%d, %d), want (50, 0)", effective, displaced)
|
||||
}
|
||||
// Balanced never displaces anything.
|
||||
if effective, displaced := applyProfileTurnBudget(balanced, 0, 80); effective != 80 || displaced != 0 {
|
||||
t.Fatalf("balanced over resolved 80 = (%d, %d), want (80, 0)", effective, displaced)
|
||||
}
|
||||
}
|
||||
|
||||
// The no-regression invariant at the options level: selecting balanced leaves
|
||||
// the options byte-identical to not selecting a profile at all, and produces
|
||||
// no loop policy.
|
||||
func TestExecProfileBalancedLeavesOptionsUntouched(t *testing.T) {
|
||||
options := execOptions{execProfile: "balanced"}
|
||||
reference := options
|
||||
profile, _, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(options, reference) {
|
||||
t.Fatalf("balanced changed the options: %+v vs %+v", options, reference)
|
||||
}
|
||||
if policy := profile.Policy(80, false); policy != nil {
|
||||
t.Fatalf("balanced must produce a nil policy, got %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
// --exec-profile= with an inline empty value must error like its two-token
|
||||
// form and the sibling --mode=, not silently run the default posture (the
|
||||
// classic shell hazard: an unset variable expanding into the inline form).
|
||||
func TestParseExecProfileInlineEmptyIsError(t *testing.T) {
|
||||
_, _, err := parseExecArgs([]string{"--exec-profile=", "hello"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--exec-profile requires a value") {
|
||||
t.Fatalf("expected a required-value error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Full echo-provider run: the selected profile must be stamped into the
|
||||
// per-turn trace so benchmark attribution can group runs by posture.
|
||||
func TestRunExecTraceRecordsSelectedProfile(t *testing.T) {
|
||||
tracePath := filepath.Join(t.TempDir(), "trace.ndjson")
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{
|
||||
"exec", "--exec-profile", "fast", "--trace", tracePath, "hello",
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
raw, err := os.ReadFile(tracePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read trace: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"profile":"fast"`) {
|
||||
t.Fatalf("trace must record the selected profile, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit --spec-reasoning-effort governs the spec draft's effort, so the
|
||||
// escalation effort-restore must not arm there even when the profile filled
|
||||
// the (unused) main effort.
|
||||
func TestSpecProfileEffortFilled(t *testing.T) {
|
||||
if !specProfileEffortFilled(true, "") {
|
||||
t.Fatal("no explicit spec effort: the profile's fill governs, restore must arm")
|
||||
}
|
||||
if specProfileEffortFilled(true, "high") {
|
||||
t.Fatal("an explicit spec effort must disarm the effort restore")
|
||||
}
|
||||
if specProfileEffortFilled(false, "") {
|
||||
t.Fatal("nothing filled, nothing to restore")
|
||||
}
|
||||
}
|
||||
|
||||
// The trace label deliberately records the SELECTED profile, balanced
|
||||
// included, so captures are self-describing. Run behavior stays identical
|
||||
// (balanced fills nothing and arms no policy); the label is the one intended
|
||||
// difference in the opt-in trace artifact.
|
||||
func TestRunExecTraceRecordsBalancedSelection(t *testing.T) {
|
||||
tracePath := filepath.Join(t.TempDir(), "trace.ndjson")
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{
|
||||
"exec", "--exec-profile", "balanced", "--trace", tracePath, "hello",
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
raw, err := os.ReadFile(tracePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read trace: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"profile":"balanced"`) {
|
||||
t.Fatalf("trace must record the balanced selection, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecUnknownExecProfileIsUsageError(t *testing.T) {
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{
|
||||
"exec", "--exec-profile", "turbo", "hello",
|
||||
})
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr, "unknown execution profile") {
|
||||
t.Fatalf("expected an unknown-profile usage error, got %q", stderr)
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,11 @@ func preflightExecSession(options execOptions) error {
|
||||
if session == nil {
|
||||
return execUsageError{"Zero session not found: " + options.resume}
|
||||
}
|
||||
if !sessions.IsResumableKind(session.SessionKind) {
|
||||
return execUsageError{"Zero session is not resumable: " + options.resume}
|
||||
}
|
||||
case options.resumeLatest:
|
||||
latest, err := store.Latest()
|
||||
latest, err := store.LatestResumable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func TestPreflightRejectsSideSessions(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
store := sessions.NewStore(sessions.StoreOptions{})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide}); err != nil {
|
||||
t.Fatalf("create side session: %v", err)
|
||||
}
|
||||
|
||||
err := preflightExecSession(execOptions{resumeLatest: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "No Zero sessions available to resume") {
|
||||
t.Fatalf("preflightExecSession error = %v, want no resumable sessions", err)
|
||||
}
|
||||
|
||||
err = preflightExecSession(execOptions{resume: "side"})
|
||||
if err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: side") {
|
||||
t.Fatalf("explicit side-session preflight error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,11 @@ type execSpecDraftRun struct {
|
||||
reasoningEffort string
|
||||
specPermissionMode agent.PermissionMode
|
||||
notifier *notify.Notifier
|
||||
// profilePolicy carries the selected execution profile's escalation policy
|
||||
// (nil when none): the profile displaces resolved.MaxTurns before this
|
||||
// path branches off, so the spec-draft run needs the same safety net as
|
||||
// the main run.
|
||||
profilePolicy *agent.ProfilePolicy
|
||||
}
|
||||
|
||||
type execSpecDraftInfo struct {
|
||||
@@ -119,6 +124,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
ReasoningEffort: run.reasoningEffort,
|
||||
Profile: run.profilePolicy,
|
||||
Cwd: run.workspaceRoot,
|
||||
SystemPrompt: specmode.DraftSystemPrompt,
|
||||
Images: run.images,
|
||||
|
||||
@@ -549,10 +549,14 @@ func TestExecMemberAutoToolListIncludesMutators(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
||||
// --profile (legacy, inert model profile) and --exec-profile (execution
|
||||
// profile) are distinct flags and must coexist on one invocation.
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{
|
||||
"exec",
|
||||
"--profile",
|
||||
"fast",
|
||||
"--exec-profile",
|
||||
"balanced",
|
||||
"--reasoning-effort",
|
||||
"low",
|
||||
"hello",
|
||||
|
||||
@@ -216,10 +216,10 @@ func parseNonEmptySessionsFlag(flag string, value string) (string, error) {
|
||||
func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
|
||||
kind := sessions.SessionKind(strings.ToLower(strings.TrimSpace(value)))
|
||||
switch kind {
|
||||
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
|
||||
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSide, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
|
||||
return kind, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, spec-draft, or spec-impl.", value)}
|
||||
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, side, spec-draft, or spec-impl.", value)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ Commands:
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
--kind <kind> Filter list by fork, child, spec-draft, or spec-impl
|
||||
--kind <kind> Filter list by fork, child, side, spec-draft, or spec-impl
|
||||
--sequence <n> Rewind target sequence (rewind-plan, rewind)
|
||||
--event <id> Rewind target event id (rewind-plan, rewind)
|
||||
--exclude-target Drop the target event (rewind-plan, rewind)
|
||||
|
||||
@@ -233,6 +233,9 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide, Title: "BTW side"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -249,6 +252,17 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
|
||||
t.Fatalf("filtered sessions output = %q", output)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sessions", "list", "--kind", "side"}, &stdout, &stderr, appDeps{
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess || !strings.Contains(stdout.String(), "side") || strings.Contains(stdout.String(), "regular") {
|
||||
t.Fatalf("sessions list --kind side exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sessions", "tree", "draft", "--kind", "spec-draft"}, &stdout, &stderr, appDeps{
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Package execprofile defines named execution profiles: bundles of loop-posture
|
||||
// knobs (turn budget, reasoning effort, self-correction, escalation triggers)
|
||||
// that tune how hard the agent loop tries, independent of which model runs.
|
||||
// Profiles COMPOSE with --mode: a mode answers "what runs" (model, tools),
|
||||
// a profile answers "how hard the loop tries". Selection precedence is
|
||||
// explicit flag > --mode > profile, enforced by the callers applying profiles
|
||||
// last with the same fill-only-if-unset rule modes use.
|
||||
//
|
||||
// The catalog is deliberately tiny and value-stable: balanced is the empty
|
||||
// profile (selecting it leaves the run's options, budget, and loop behavior
|
||||
// identical to an unflagged run — asserted by test; only the opt-in trace and
|
||||
// bench artifacts record the selected profile name, by design, so captures are
|
||||
// self-describing), fast trades turn budget and effort for latency with a
|
||||
// one-shot escalation back to the displaced posture as the safety net, and
|
||||
// thorough raises the budget and arms full self-correction.
|
||||
package execprofile
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// Profile is a named execution posture. Zero-valued fields inherit the run's
|
||||
// existing value (flag, mode, config, or built-in default) — a profile only
|
||||
// ever fills knobs the caller left unset, except MaxTurns which REPLACES the
|
||||
// resolved budget (that displacement is what escalation restores).
|
||||
type Profile struct {
|
||||
// Name identifies the profile in traces, help text, and diagnostics.
|
||||
Name string
|
||||
// MaxTurns replaces the resolved per-run tool-turn budget when > 0 and the
|
||||
// user did not pass an explicit --max-turns. The displaced resolved value
|
||||
// becomes the escalation target (see Policy).
|
||||
MaxTurns int
|
||||
// ReasoningEffort fills the run's effort when the user left it unset. It
|
||||
// still flows through the model-supported gating at the call site, so an
|
||||
// unsupported level degrades exactly like an explicit flag would.
|
||||
ReasoningEffort string
|
||||
// SelfCorrect arms the post-edit verify-and-correct loop. Profiles can only
|
||||
// turn it ON (the flag is presence-only, so false is indistinguishable from
|
||||
// unset and must never override an explicit opt-in).
|
||||
SelfCorrect bool
|
||||
|
||||
// Escalation triggers. All zero means the profile never escalates and
|
||||
// Policy returns nil (the loop stays byte-identical to a nil-profile run).
|
||||
// Targets are NOT part of the catalog: escalation restores the values the
|
||||
// profile displaced at selection time, never invented ones, so targets are
|
||||
// stamped by Policy from what the caller measured.
|
||||
EscalateOnToolFailureStreak int
|
||||
EscalateOnCompletionUncertain int
|
||||
EscalateOnSelfCorrectFailure bool
|
||||
EscalateOnRiskyMutation sandbox.RiskLevel
|
||||
}
|
||||
|
||||
// The catalog. Values marked provisional are floors/ceilings derived from the
|
||||
// Phase 0 baseline's read-class evidence (successful nav runs used single-digit
|
||||
// turns; the mutating classes produced no successful samples to tune from) and
|
||||
// are expected to be re-tuned from the post-oracle-fix re-capture. The
|
||||
// escalation safety net is what makes shipping provisional Fast values safe:
|
||||
// a run that hits trouble gets its displaced budget back mid-run.
|
||||
var (
|
||||
// Balanced is the default posture: empty on purpose. Selecting it changes
|
||||
// nothing — the no-regression invariant of the whole feature.
|
||||
Balanced = Profile{Name: "balanced"}
|
||||
|
||||
// Fast starts cheap (30 turns, low effort) and arms every escalation
|
||||
// trigger so a struggling run restores its displaced posture: two
|
||||
// same-tool retriable failures in a row, a second uncertain completion
|
||||
// evaluation, a failing self-correction cycle, or a critical-risk
|
||||
// mutation. The risk threshold is deliberately Critical, not High: the
|
||||
// sandbox classifies EVERY shell command as at least high risk before any
|
||||
// command analysis, so a High trigger would fire on the first `go test`
|
||||
// of virtually any coding task and spend the one-shot on turn one.
|
||||
// Critical marks the genuinely scary categories (destructive commands,
|
||||
// piped installers, out-of-workspace writes, network mutations).
|
||||
Fast = Profile{
|
||||
Name: "fast",
|
||||
MaxTurns: 30,
|
||||
ReasoningEffort: "low",
|
||||
EscalateOnToolFailureStreak: 2,
|
||||
EscalateOnCompletionUncertain: 2,
|
||||
EscalateOnSelfCorrectFailure: true,
|
||||
EscalateOnRiskyMutation: sandbox.RiskCritical,
|
||||
}
|
||||
|
||||
// Thorough doubles the default budget, asks for high effort, and arms the
|
||||
// full post-edit verify-and-correct loop. Already the maximum posture, so
|
||||
// it has nothing to escalate to.
|
||||
Thorough = Profile{
|
||||
Name: "thorough",
|
||||
MaxTurns: 160,
|
||||
ReasoningEffort: "high",
|
||||
SelfCorrect: true,
|
||||
}
|
||||
)
|
||||
|
||||
var catalog = map[string]Profile{
|
||||
Balanced.Name: Balanced,
|
||||
Fast.Name: Fast,
|
||||
Thorough.Name: Thorough,
|
||||
}
|
||||
|
||||
// Lookup resolves a profile by name, case-insensitively and ignoring
|
||||
// surrounding whitespace.
|
||||
func Lookup(name string) (Profile, bool) {
|
||||
profile, ok := catalog[strings.ToLower(strings.TrimSpace(name))]
|
||||
return profile, ok
|
||||
}
|
||||
|
||||
// Names returns the catalog's profile names, sorted, for usage errors and help.
|
||||
func Names() []string {
|
||||
names := make([]string, 0, len(catalog))
|
||||
for name := range catalog {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// Policy projects the profile into the loop-facing ProfilePolicy.
|
||||
//
|
||||
// displacedMaxTurns is the turn budget this profile displaced at selection time
|
||||
// (what the run would have used without it) and becomes the escalation's
|
||||
// restore target; pass 0 when the profile did not displace the budget (e.g.
|
||||
// the user pinned --max-turns explicitly) so escalation leaves the ceiling
|
||||
// untouched. effortFilled reports whether the profile actually filled the
|
||||
// run's reasoning effort (it backs off when the user set one explicitly); the
|
||||
// displaced effort is then "" (the provider default) by construction, which
|
||||
// the escalation restores via RestoreDefaultEffort — a plain "" target would
|
||||
// mean "leave untouched".
|
||||
//
|
||||
// Profiles with no armed triggers return nil: the loop treats a nil policy as
|
||||
// "no profile" (no observation, no counters), which keeps balanced and
|
||||
// thorough runs byte-identical to the same knob values set by hand.
|
||||
func (p Profile) Policy(displacedMaxTurns int, effortFilled bool) *agent.ProfilePolicy {
|
||||
if p.EscalateOnToolFailureStreak == 0 && p.EscalateOnCompletionUncertain == 0 &&
|
||||
!p.EscalateOnSelfCorrectFailure && p.EscalateOnRiskyMutation == "" {
|
||||
return nil
|
||||
}
|
||||
return &agent.ProfilePolicy{
|
||||
Name: p.Name,
|
||||
Escalate: &agent.PostureEscalation{
|
||||
MaxTurns: displacedMaxTurns,
|
||||
RestoreDefaultEffort: effortFilled,
|
||||
OnToolFailureStreak: p.EscalateOnToolFailureStreak,
|
||||
OnCompletionUncertain: p.EscalateOnCompletionUncertain,
|
||||
OnSelfCorrectFailure: p.EscalateOnSelfCorrectFailure,
|
||||
OnRiskyMutation: p.EscalateOnRiskyMutation,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package execprofile
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// The no-regression invariant of the whole feature: balanced must be the empty
|
||||
// profile (nothing but a name), so selecting it cannot change a run.
|
||||
func TestBalancedProfileIsEmpty(t *testing.T) {
|
||||
profile, ok := Lookup("balanced")
|
||||
if !ok {
|
||||
t.Fatal("balanced must exist in the catalog")
|
||||
}
|
||||
if profile.Name != "balanced" {
|
||||
t.Fatalf("Name = %q, want %q", profile.Name, "balanced")
|
||||
}
|
||||
profile.Name = ""
|
||||
if !reflect.DeepEqual(profile, Profile{}) {
|
||||
t.Fatalf("balanced must be zero-valued apart from its name, got %+v", profile)
|
||||
}
|
||||
if policy := Balanced.Policy(80, false); policy != nil {
|
||||
t.Fatalf("balanced Policy must be nil (byte-identical loop), got %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIsCaseAndSpaceInsensitive(t *testing.T) {
|
||||
for _, name := range []string{"fast", "Fast", " FAST "} {
|
||||
if _, ok := Lookup(name); !ok {
|
||||
t.Fatalf("Lookup(%q) should resolve", name)
|
||||
}
|
||||
}
|
||||
if _, ok := Lookup("turbo"); ok {
|
||||
t.Fatal("Lookup(turbo) should not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesAreSorted(t *testing.T) {
|
||||
want := []string{"balanced", "fast", "thorough"}
|
||||
if got := Names(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Names() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Escalation targets must be the displaced values only — the catalog carries
|
||||
// triggers, the call site supplies what was displaced, and nothing else may
|
||||
// appear in the escalation.
|
||||
func TestFastPolicyTargetsDisplacedValuesOnly(t *testing.T) {
|
||||
policy := Fast.Policy(80, true)
|
||||
if policy == nil {
|
||||
t.Fatal("fast must arm an escalation policy")
|
||||
}
|
||||
if policy.Name != "fast" {
|
||||
t.Fatalf("policy.Name = %q, want fast", policy.Name)
|
||||
}
|
||||
esc := policy.Escalate
|
||||
if esc == nil {
|
||||
t.Fatal("fast policy must carry an escalation")
|
||||
}
|
||||
if esc.MaxTurns != 80 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, want the displaced 80", esc.MaxTurns)
|
||||
}
|
||||
if esc.ReasoningEffort != "" {
|
||||
t.Fatalf("Escalate.ReasoningEffort = %q, want empty (displaced effort is the provider default by construction)", esc.ReasoningEffort)
|
||||
}
|
||||
if !esc.RestoreDefaultEffort {
|
||||
t.Fatal("effort was profile-filled, so escalation must restore the provider default")
|
||||
}
|
||||
if esc.RestoreCompletionGate {
|
||||
t.Fatal("fast does not touch the completion gate, so escalation must not either")
|
||||
}
|
||||
if esc.OnToolFailureStreak != Fast.EscalateOnToolFailureStreak ||
|
||||
esc.OnCompletionUncertain != Fast.EscalateOnCompletionUncertain ||
|
||||
esc.OnSelfCorrectFailure != Fast.EscalateOnSelfCorrectFailure ||
|
||||
esc.OnRiskyMutation != Fast.EscalateOnRiskyMutation {
|
||||
t.Fatalf("escalation triggers must mirror the catalog, got %+v", esc)
|
||||
}
|
||||
}
|
||||
|
||||
// The effort restore arms only when the profile actually filled the effort:
|
||||
// with an explicit user effort the profile backed off, so escalation must not
|
||||
// clear the user's choice.
|
||||
func TestFastPolicyExplicitEffortIsNeverRestored(t *testing.T) {
|
||||
policy := Fast.Policy(80, false)
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("fast must still arm its triggers")
|
||||
}
|
||||
if policy.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("the profile did not fill the effort, so escalation must leave it alone")
|
||||
}
|
||||
}
|
||||
|
||||
// A profile that did not displace the budget (explicit --max-turns pinned it)
|
||||
// must not let escalation move the ceiling at all.
|
||||
func TestFastPolicyZeroDisplacedLeavesCeilingUntouched(t *testing.T) {
|
||||
policy := Fast.Policy(0, false)
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("fast must still arm its triggers with a zero displaced budget")
|
||||
}
|
||||
if policy.Escalate.MaxTurns != 0 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, want 0 (no displaced value to restore)", policy.Escalate.MaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
// Thorough is already the maximum posture: no triggers, so no policy — the
|
||||
// loop must stay byte-identical to the same knobs set by hand.
|
||||
func TestThoroughPolicyIsNil(t *testing.T) {
|
||||
if policy := Thorough.Policy(80, true); policy != nil {
|
||||
t.Fatalf("thorough Policy must be nil, got %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the catalog's provisional values so a retune is a deliberate,
|
||||
// test-visible diff (these floors came from the Phase 0 baseline's read-class
|
||||
// evidence and are expected to move after the post-oracle-fix re-capture).
|
||||
func TestCatalogProvisionalValues(t *testing.T) {
|
||||
if Fast.MaxTurns != 30 || Fast.ReasoningEffort != "low" || Fast.SelfCorrect {
|
||||
t.Fatalf("fast knobs changed: %+v", Fast)
|
||||
}
|
||||
// Critical, not High: the sandbox classifies every shell command as at
|
||||
// least high risk, so a High trigger would spend the one-shot escalation
|
||||
// on the first benign `go test` of any coding task.
|
||||
if Fast.EscalateOnRiskyMutation != sandbox.RiskCritical {
|
||||
t.Fatalf("fast risky-mutation trigger = %q, want %q", Fast.EscalateOnRiskyMutation, sandbox.RiskCritical)
|
||||
}
|
||||
if Thorough.MaxTurns != 160 || Thorough.ReasoningEffort != "high" || !Thorough.SelfCorrect {
|
||||
t.Fatalf("thorough knobs changed: %+v", Thorough)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,26 @@ func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseU
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateResolvedEndpoints applies the shared https/loopback endpoint rule to
|
||||
// every non-empty endpoint in the resolved metadata — configured or discovered
|
||||
// alike — so discovery metadata can never downgrade an MCP login to an insecure
|
||||
// or attacker-controlled authorization, token, or registration endpoint.
|
||||
func validateResolvedEndpoints(metadata authServerMetadata) error {
|
||||
for _, ep := range []struct{ kind, url string }{
|
||||
{"authorization", metadata.AuthorizationEndpoint},
|
||||
{"token", metadata.TokenEndpoint},
|
||||
{"registration", metadata.RegistrationEndpoint},
|
||||
} {
|
||||
if strings.TrimSpace(ep.url) == "" {
|
||||
continue
|
||||
}
|
||||
if err := oauth.ValidateEndpointURL(ep.url); err != nil {
|
||||
return fmt.Errorf("mcp oauth: %s endpoint: %w", ep.kind, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAuthorizationServer discovers metadata and applies explicit config
|
||||
// overrides. Configured endpoints take precedence over discovered ones, and act
|
||||
// as a fallback when discovery fails or omits a value.
|
||||
@@ -148,6 +168,9 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
|
||||
TokenEndpoint: strings.TrimSpace(cfg.TokenEndpoint),
|
||||
RegistrationEndpoint: strings.TrimSpace(cfg.RegistrationEndpoint),
|
||||
}
|
||||
if err := validateResolvedEndpoints(metadata); err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
@@ -179,6 +202,9 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
|
||||
if strings.TrimSpace(metadata.TokenEndpoint) == "" {
|
||||
return authServerMetadata{}, errors.New("no token endpoint discovered or configured")
|
||||
}
|
||||
if err := validateResolvedEndpoints(metadata); err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,22 @@ func TestResolveEndpointsFallsBackToConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Discovered MCP metadata that serves an insecure endpoint must be rejected
|
||||
// before use, so discovery cannot downgrade the MCP login to an
|
||||
// attacker-controlled origin (issue #511).
|
||||
func TestResolveAuthorizationServerRejectsInsecureDiscovered(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"issuer":"` + server.URL + `","token_endpoint":"` + server.URL + `/token","authorization_endpoint":"http://evil.example/authorize"}`))
|
||||
})
|
||||
_, err := resolveAuthorizationServer(context.Background(), server.Client(), server.URL, OAuthConfig{})
|
||||
if err == nil || !strings.Contains(err.Error(), "authorization endpoint") {
|
||||
t.Fatalf("err = %v, want insecure authorization endpoint rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
// roundTripperFunc adapts a function to http.RoundTripper so a test can
|
||||
// observe every outbound request.
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
+21
-5
@@ -39,6 +39,13 @@ func BuildAuthorizationURL(cfg Config, pkce PKCE, state, redirectURI string, ext
|
||||
if endpoint == "" {
|
||||
return "", errors.New("oauth: no authorization endpoint configured")
|
||||
}
|
||||
// Backstop: validate the authorization endpoint at the shared choke point
|
||||
// both the provider and MCP flows build their browser URL through, so a
|
||||
// discovery-downgraded endpoint can never open in the browser even if a
|
||||
// merge site missed it.
|
||||
if err := ValidateEndpointURL(endpoint); err != nil {
|
||||
return "", err
|
||||
}
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oauth: parse authorization endpoint: %w", err)
|
||||
@@ -83,13 +90,15 @@ func isReservedAuthParam(key string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// validateTokenEndpoint refuses to send a credential to a token endpoint that is
|
||||
// not https, unless it is a loopback host (mirrors oauth2-client.js
|
||||
// validateTokenEndpointURL). This prevents leaking codes/tokens over cleartext.
|
||||
func validateTokenEndpoint(endpoint string) error {
|
||||
// ValidateEndpointURL refuses an OAuth endpoint that is not https, unless it is
|
||||
// a loopback host (mirrors oauth2-client.js validateTokenEndpointURL). Every
|
||||
// credential-bearing endpoint — configured OR learned from discovery — must
|
||||
// pass this single rule, so discovery metadata can never downgrade a login to
|
||||
// cleartext or redirect it to an attacker-controlled http origin.
|
||||
func ValidateEndpointURL(endpoint string) error {
|
||||
parsed, err := url.Parse(trimmed(endpoint))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("oauth: invalid token endpoint %q", endpoint)
|
||||
return fmt.Errorf("oauth: invalid endpoint %q", endpoint)
|
||||
}
|
||||
if parsed.Scheme == "https" {
|
||||
return nil
|
||||
@@ -100,6 +109,13 @@ func validateTokenEndpoint(endpoint string) error {
|
||||
return fmt.Errorf("%w: %s", ErrInsecureTokenEndpoint, parsed.Scheme+"://"+parsed.Host)
|
||||
}
|
||||
|
||||
// validateTokenEndpoint checks a token endpoint against the shared endpoint rule
|
||||
// (kept as a named helper for the token-exchange and device call sites). This
|
||||
// prevents leaking codes/tokens over cleartext.
|
||||
func validateTokenEndpoint(endpoint string) error {
|
||||
return ValidateEndpointURL(endpoint)
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
|
||||
@@ -112,6 +112,20 @@ func TestBuildAuthorizationURLRejectsPlain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The shared choke point both flows build their browser URL through must refuse
|
||||
// an insecure authorization endpoint, so a discovery-downgraded endpoint can
|
||||
// never open in the browser even if a merge site missed it (issue #511).
|
||||
func TestBuildAuthorizationURLRejectsInsecureEndpoint(t *testing.T) {
|
||||
_, err := BuildAuthorizationURL(
|
||||
Config{AuthorizationEndpoint: "http://evil.example/authorize", ClientID: "c"},
|
||||
PKCE{Method: MethodS256, Challenge: "chal"},
|
||||
"state", "http://127.0.0.1/cb", nil,
|
||||
)
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenEndpoint(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://auth.example.com/token": true,
|
||||
|
||||
@@ -218,13 +218,26 @@ func (m *Manager) resolveEndpoints(ctx context.Context, cfg Config) (Config, err
|
||||
// may already be sufficient for the chosen flow. Only merge on success (and
|
||||
// never return from the error branch, which would trip the nilerr linter).
|
||||
if meta, err := m.discover(ctx, cfg.IssuerURL); err == nil {
|
||||
if cfg.AuthorizationEndpoint == "" {
|
||||
// A discovered endpoint must pass the same https/loopback rule as a
|
||||
// configured one before it is merged, so discovery metadata can never
|
||||
// silently downgrade the login to an insecure or attacker-controlled
|
||||
// endpoint. Fail closed on a bad endpoint rather than merging it.
|
||||
if cfg.AuthorizationEndpoint == "" && meta.AuthorizationEndpoint != "" {
|
||||
if err := ValidateEndpointURL(meta.AuthorizationEndpoint); err != nil {
|
||||
return cfg, fmt.Errorf("oauth: discovered authorization endpoint: %w", err)
|
||||
}
|
||||
cfg.AuthorizationEndpoint = meta.AuthorizationEndpoint
|
||||
}
|
||||
if cfg.TokenEndpoint == "" {
|
||||
if cfg.TokenEndpoint == "" && meta.TokenEndpoint != "" {
|
||||
if err := ValidateEndpointURL(meta.TokenEndpoint); err != nil {
|
||||
return cfg, fmt.Errorf("oauth: discovered token endpoint: %w", err)
|
||||
}
|
||||
cfg.TokenEndpoint = meta.TokenEndpoint
|
||||
}
|
||||
if cfg.DeviceAuthorizationEndpoint == "" {
|
||||
if cfg.DeviceAuthorizationEndpoint == "" && meta.DeviceAuthorizationEndpoint != "" {
|
||||
if err := ValidateEndpointURL(meta.DeviceAuthorizationEndpoint); err != nil {
|
||||
return cfg, fmt.Errorf("oauth: discovered device authorization endpoint: %w", err)
|
||||
}
|
||||
cfg.DeviceAuthorizationEndpoint = meta.DeviceAuthorizationEndpoint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -200,6 +201,24 @@ func TestManagerGetFreshDiscoversIssuerEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A discovery document that serves an insecure (non-https, non-loopback)
|
||||
// endpoint must be rejected before it is merged, so discovery metadata cannot
|
||||
// silently downgrade the login to an attacker-controlled origin (issue #511).
|
||||
func TestResolveEndpointsRejectsInsecureDiscoveredEndpoint(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Loopback issuer, but an attacker-controlled http authorization endpoint.
|
||||
_, _ = io.WriteString(w, `{"issuer":"`+server.URL+`","token_endpoint":"`+server.URL+`/token","authorization_endpoint":"http://evil.example/authorize"}`)
|
||||
})
|
||||
m := managerFor(t, map[string]string{}, nil)
|
||||
_, err := m.resolveEndpoints(context.Background(), Config{IssuerURL: server.URL})
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("resolveEndpoints err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetFreshRefreshesExpired(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"fresh-at","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && ! grep -RIn 'MaxRetries =' ."
|
||||
"go test ./... && ! grep --exclude=.zero-answer.txt -RIn 'MaxRetries =' ."
|
||||
],
|
||||
"oracleTest": "package edit\n\nvar _ = RetryLimit\n"
|
||||
},
|
||||
@@ -146,7 +146,7 @@
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && ! grep -R receieves . && grep -R receives ."
|
||||
"go test ./... && ! grep --exclude=.zero-answer.txt -R receieves . && grep --exclude=.zero-answer.txt -R receives ."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -400,7 +400,7 @@
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && ! grep -R 'package refactor' . && grep -R 'package zeroapp' ."
|
||||
"go test ./... && ! grep --exclude=.zero-answer.txt -R 'package refactor' . && grep --exclude=.zero-answer.txt -R 'package zeroapp' ."
|
||||
],
|
||||
"oracleTest": "package zeroapp\n\nvar _ = Config{}\n"
|
||||
},
|
||||
@@ -426,7 +426,7 @@
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && ! grep -R 'func Wrapper' ."
|
||||
"go test ./... && ! grep --exclude=.zero-answer.txt -R 'func Wrapper' ."
|
||||
],
|
||||
"oracleTest": "package refactor\n\nimport \"testing\"\n\n// TestGreetWrapped proves greetWrapped still exists and behaves as the inlined\n// Wrapper did: deleting both Wrapper and greetWrapped (or stubbing greetWrapped\n// to return \"\") fails to compile or run, so the oracle can't be passed by\n// removing the target along with its caller. Paired with the ! grep 'func\n// Wrapper' verificationCommand that proves Wrapper itself is gone.\nfunc TestGreetWrapped(t *testing.T) {\n\tif got := greetWrapped(\"x\"); got != \"hello, x\" {\n\t\tt.Fatalf(\"greetWrapped(x) = %q, want %q\", got, \"hello, x\")\n\t}\n}\n"
|
||||
},
|
||||
|
||||
@@ -85,6 +85,10 @@ type RunContext struct {
|
||||
Model string
|
||||
Mode string
|
||||
SelfCorrect bool
|
||||
// ExecProfile, when non-empty, is forwarded to the agent invocation as
|
||||
// --exec-profile (turn benchmark runner; the task benchmark leaves it
|
||||
// empty).
|
||||
ExecProfile string
|
||||
}
|
||||
|
||||
// TaskRunner runs a single benchmark task and returns its outcome.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
)
|
||||
|
||||
@@ -40,7 +41,25 @@ import (
|
||||
// prevents a v2→v2 cross-version comparison from misreading the jump as a model
|
||||
// improvement. New counts: correctness 34 (edit 10 + fix 8 + nav 10 + refactor
|
||||
// 6), build 0, latency 14 (longproc 4 + longctx 4 + parallel 6).
|
||||
const TurnSchemaVersion = 3
|
||||
//
|
||||
// v4 adds tasksErrored: the number of tasks whose every iteration produced no
|
||||
// accepted benchmark sample. That covers pre-run failures (missing binary,
|
||||
// spawn error, process crash) and harness errors after a successful agent run
|
||||
// (e.g. oracle stamping failed) alike — in every case the iteration yields no
|
||||
// usable measurement. Errored tasks were previously visible only in warnings,
|
||||
// so a run where nothing was actually measured still printed a clean-looking
|
||||
// 0% pass summary and exited 0. tasksErrored makes that state first-class:
|
||||
// the summary surfaces it, and the turn command exits nonzero when every
|
||||
// attempted task errored. Accounting: an errored oracle task stays in its
|
||||
// tier denominator as a failure; an errored latency-only task counts in
|
||||
// latencyOnlyTasks and, as always, in no pass rate.
|
||||
//
|
||||
// v5 is ADDITIVE ONLY: execProfile records the execution profile the run was
|
||||
// benchmarked under (omitted when none was selected), so profile A/B captures
|
||||
// are self-describing and two reports can't be compared without noticing they
|
||||
// ran different postures. No existing field changed shape or meaning; a v4
|
||||
// consumer reading a v5 report misses only the new field.
|
||||
const TurnSchemaVersion = 5
|
||||
|
||||
// TurnRunner runs one benchmark task and reports its outcome plus the captured
|
||||
// per-turn trace. A non-nil Err means the run failed to execute (process crash);
|
||||
@@ -67,6 +86,10 @@ type TurnBenchConfig struct {
|
||||
Model string
|
||||
Mode string
|
||||
SelfCorrect bool
|
||||
// ExecProfile, when non-empty, runs every task under the named execution
|
||||
// profile (zero exec --exec-profile) and stamps it into the result so the
|
||||
// report is self-describing for profile A/B comparisons.
|
||||
ExecProfile string
|
||||
Version string
|
||||
Commit string
|
||||
// Iterations is how many times each task is run. The per-process `zero exec`
|
||||
@@ -139,11 +162,13 @@ type TurnBenchResult struct {
|
||||
Suite string `json:"suite"`
|
||||
Model string `json:"model"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ExecProfile string `json:"execProfile,omitempty"`
|
||||
SelfCorrect bool `json:"selfCorrect"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Date string `json:"date"`
|
||||
TasksAttempted int `json:"tasksAttempted"`
|
||||
TasksErrored int `json:"tasksErrored"`
|
||||
TasksVerified int `json:"tasksVerified"`
|
||||
TasksPassed int `json:"tasksPassed"`
|
||||
LatencyOnlyTasks int `json:"latencyOnlyTasks"`
|
||||
@@ -189,6 +214,19 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
if cfg.Runner == nil {
|
||||
return TurnBenchResult{}, errors.New("turn benchmark requires a runner")
|
||||
}
|
||||
// Canonicalize the profile once at the boundary so every consumer — the
|
||||
// child exec args, the stamped result, the summary — carries the same
|
||||
// catalog name regardless of the caller's casing/whitespace, and a direct
|
||||
// library caller cannot slip an unknown name into a report the way the CLI
|
||||
// (which validates at parse time) cannot.
|
||||
benchProfile := strings.TrimSpace(cfg.ExecProfile)
|
||||
if benchProfile != "" {
|
||||
profile, ok := execprofile.Lookup(benchProfile)
|
||||
if !ok {
|
||||
return TurnBenchResult{}, fmt.Errorf("unknown execution profile %q (valid: %s)", cfg.ExecProfile, strings.Join(execprofile.Names(), ", "))
|
||||
}
|
||||
benchProfile = profile.Name
|
||||
}
|
||||
iterations := cfg.Iterations
|
||||
if iterations < 1 {
|
||||
iterations = 1
|
||||
@@ -197,7 +235,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
rc := RunContext{Model: cfg.Model, Mode: cfg.Mode, SelfCorrect: cfg.SelfCorrect}
|
||||
rc := RunContext{Model: cfg.Model, Mode: cfg.Mode, SelfCorrect: cfg.SelfCorrect, ExecProfile: benchProfile}
|
||||
|
||||
perSpanSamples := map[string][]float64{}
|
||||
classWalls := map[string][]float64{}
|
||||
@@ -227,6 +265,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
Suite: strings.TrimSpace(set.ID),
|
||||
Model: strings.TrimSpace(cfg.Model),
|
||||
Mode: strings.TrimSpace(cfg.Mode),
|
||||
ExecProfile: benchProfile,
|
||||
SelfCorrect: cfg.SelfCorrect,
|
||||
Version: strings.TrimSpace(cfg.Version),
|
||||
Commit: strings.TrimSpace(cfg.Commit),
|
||||
@@ -249,6 +288,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
// runner is cold-start, so a flaky pass on one iteration and a fail on
|
||||
// another is a real regression signal, not noise to average away.
|
||||
passedForTask := true
|
||||
erroredIterations := 0
|
||||
for iter := 0; iter < iterations; iter++ {
|
||||
outcome := cfg.Runner(ctx, task, rc)
|
||||
if outcome.TraceIssue != "" {
|
||||
@@ -266,6 +306,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
Message: fmt.Sprintf("task %s: %v", task.ID, outcome.Err),
|
||||
})
|
||||
passedForTask = false
|
||||
erroredIterations++
|
||||
continue
|
||||
}
|
||||
if !outcome.Passed {
|
||||
@@ -306,6 +347,14 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
// counters. A latency-only task (no verificationCommand) is never counted
|
||||
// in any pass rate even when the runner reports Passed — an exit-0
|
||||
// read-only run proves the turn ran, not that the answer was right.
|
||||
if erroredIterations == iterations {
|
||||
// Every iteration produced no accepted sample (spawn failure or a
|
||||
// harness error after the run). The task still counts in its tier
|
||||
// below — an oracle task as a failure, a latency-only task in no
|
||||
// pass rate as always — but the errored state is first-class so a
|
||||
// broken run can never print a clean-looking summary.
|
||||
result.TasksErrored++
|
||||
}
|
||||
hasOracle := len(task.VerificationCommand) > 0
|
||||
switch {
|
||||
case !hasOracle:
|
||||
@@ -456,9 +505,26 @@ func FormatTurnBenchSummary(result TurnBenchResult) string {
|
||||
result.BuildPassedTasks, result.BuildCheckedTasks, result.BuildPassRate*100,
|
||||
result.LatencyOnlyTasks, result.Iterations),
|
||||
}
|
||||
if result.TasksErrored > 0 {
|
||||
lines = append(lines, fmt.Sprintf("ERRORED: %d task(s) produced no accepted benchmark sample (spawn/crash or harness error) — errored oracle tasks count as failures in their tier's pass rate; latency-only tasks stay out of pass rates as always", result.TasksErrored))
|
||||
shown := 0
|
||||
for _, warning := range result.Warnings {
|
||||
if warning.Metric != "run" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, " "+warning.Message)
|
||||
shown++
|
||||
if shown == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.Mode != "" {
|
||||
lines = append(lines, "mode: "+result.Mode)
|
||||
}
|
||||
if result.ExecProfile != "" {
|
||||
lines = append(lines, "exec-profile: "+result.ExecProfile)
|
||||
}
|
||||
if len(result.TopLatency) > 0 {
|
||||
lines = append(lines, "top latency sources:")
|
||||
for _, src := range result.TopLatency {
|
||||
@@ -734,6 +800,9 @@ func buildTurnExecArgs(task BenchTask, rc RunContext, tracePath string, extraArg
|
||||
if rc.SelfCorrect {
|
||||
args = append(args, "--self-correct")
|
||||
}
|
||||
if profile := strings.TrimSpace(rc.ExecProfile); profile != "" {
|
||||
args = append(args, "--exec-profile", profile)
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
args = append(args, task.Prompt)
|
||||
return args
|
||||
@@ -747,7 +816,15 @@ func ResolveBinary(explicit string) (string, error) {
|
||||
if _, err := os.Stat(v); err != nil {
|
||||
return "", fmt.Errorf("trace binary not found: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
// Pin the explicit path to an absolute one: the turn runner sets each
|
||||
// child's cmd.Dir to a per-task fixture copy, so a relative path
|
||||
// (./zero, the Makefile default) that stats fine here would fail to
|
||||
// spawn from inside every fixture dir, silently erroring all tasks.
|
||||
absolute, err := filepath.Abs(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve trace binary path: %w", err)
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
if path, err := exec.LookPath("zero"); err == nil {
|
||||
return path, nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -530,14 +531,17 @@ func TestRunTurnBenchBuildOnlyMechanism(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnSchemaVersion3 pins the schema bump. v3 records the tier
|
||||
// reclassification (refactor structural-positive and nav answer-oracles moved
|
||||
// into correctnessPassRate), so a v2->v3 cross-version comparison cannot
|
||||
// silently misread the jump as a model improvement — exactly the misread the
|
||||
// tier system exists to prevent.
|
||||
func TestTurnSchemaVersion3(t *testing.T) {
|
||||
if TurnSchemaVersion != 3 {
|
||||
t.Fatalf("TurnSchemaVersion = %d, want 3", TurnSchemaVersion)
|
||||
// TestTurnSchemaVersion pins the schema bump so a shape change is a conscious
|
||||
// decision. v3 recorded the tier reclassification (refactor structural-positive
|
||||
// and nav answer-oracles moved into correctnessPassRate). v4 adds tasksErrored:
|
||||
// tasks whose every iteration died before the agent produced a run are now
|
||||
// first-class in the report instead of visible only in warnings, so a
|
||||
// spawn-broken run cannot print a clean-looking summary or exit 0. v5 is
|
||||
// additive only: execProfile stamps the execution profile the run was
|
||||
// benchmarked under so profile A/B reports are self-describing.
|
||||
func TestTurnSchemaVersion(t *testing.T) {
|
||||
if TurnSchemaVersion != 5 {
|
||||
t.Fatalf("TurnSchemaVersion = %d, want 5", TurnSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,3 +1071,187 @@ printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-08 fmt-only-no-testing", outcome)
|
||||
}
|
||||
|
||||
func TestResolveBinaryAbsolutizesExplicitPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
name := "zero-probe.exe"
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
|
||||
resolved, err := ResolveBinary("./" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBinary: %v", err)
|
||||
}
|
||||
if !filepath.IsAbs(resolved) {
|
||||
t.Fatalf("ResolveBinary returned relative path %q; the turn runner sets cmd.Dir per task, so a relative binary fails to spawn from every fixture copy", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTurnBenchCountsErroredTasks(t *testing.T) {
|
||||
set := TaskSet{
|
||||
ID: "errored-suite",
|
||||
Tasks: []BenchTask{
|
||||
{ID: "t1", Class: "nav", Prompt: "p1", VerificationCommand: []string{"true"}},
|
||||
{ID: "t2", Class: "longproc", Prompt: "p2"},
|
||||
},
|
||||
}
|
||||
cfg := TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
Iterations: 1,
|
||||
Runner: func(context.Context, BenchTask, RunContext) TurnTaskOutcome {
|
||||
return TurnTaskOutcome{Err: errors.New("fork/exec ./zero: file does not exist")}
|
||||
},
|
||||
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
|
||||
}
|
||||
result, err := RunTurnBench(context.Background(), set, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTurnBench: %v", err)
|
||||
}
|
||||
if result.TasksErrored != 2 {
|
||||
t.Fatalf("TasksErrored=%d, want 2 (every iteration of both tasks errored)", result.TasksErrored)
|
||||
}
|
||||
if len(result.Warnings) != 2 {
|
||||
t.Fatalf("warnings=%d, want 2 run warnings", len(result.Warnings))
|
||||
}
|
||||
summary := FormatTurnBenchSummary(result)
|
||||
if !strings.Contains(summary, "ERRORED: 2 task(s)") {
|
||||
t.Fatalf("summary does not surface the errored tasks:\n%s", summary)
|
||||
}
|
||||
if !strings.Contains(summary, "fork/exec ./zero") {
|
||||
t.Fatalf("summary does not echo the underlying run error:\n%s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTurnBenchPartialErrorStillCounts(t *testing.T) {
|
||||
set := TaskSet{
|
||||
ID: "partial-suite",
|
||||
Tasks: []BenchTask{
|
||||
{ID: "ok", Class: "nav", Prompt: "p", VerificationCommand: []string{"true"}},
|
||||
{ID: "dead", Class: "nav", Prompt: "p", VerificationCommand: []string{"true"}},
|
||||
},
|
||||
}
|
||||
canned := map[string]*trace.TurnTrace{"ok": cannedTrace(100, 10, 1000)}
|
||||
inner := fakeTurnRunner(canned)
|
||||
cfg := TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
Iterations: 1,
|
||||
Runner: func(ctx context.Context, task BenchTask, rc RunContext) TurnTaskOutcome {
|
||||
if task.ID == "dead" {
|
||||
return TurnTaskOutcome{Err: errors.New("spawn failed")}
|
||||
}
|
||||
return inner(ctx, task, rc)
|
||||
},
|
||||
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
|
||||
}
|
||||
result, err := RunTurnBench(context.Background(), set, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTurnBench: %v", err)
|
||||
}
|
||||
if result.TasksErrored != 1 {
|
||||
t.Fatalf("TasksErrored=%d, want 1", result.TasksErrored)
|
||||
}
|
||||
if result.TasksAttempted != 2 {
|
||||
t.Fatalf("TasksAttempted=%d, want 2", result.TasksAttempted)
|
||||
}
|
||||
}
|
||||
|
||||
// The exec-profile passthrough: the profile must reach every task's zero exec
|
||||
// invocation as --exec-profile (with the prompt staying last) and stay out of
|
||||
// the args entirely when unset.
|
||||
func TestBuildTurnExecArgsIncludesExecProfile(t *testing.T) {
|
||||
task := BenchTask{ID: "t", Prompt: "do the thing"}
|
||||
args := buildTurnExecArgs(task, RunContext{Model: "m", ExecProfile: "fast"}, "trace.ndjson", nil)
|
||||
found := false
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "--exec-profile" && args[i+1] == "fast" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("args must carry --exec-profile fast, got %v", args)
|
||||
}
|
||||
if args[len(args)-1] != "do the thing" {
|
||||
t.Fatalf("prompt must stay the last argument, got %v", args)
|
||||
}
|
||||
|
||||
args = buildTurnExecArgs(task, RunContext{Model: "m"}, "trace.ndjson", nil)
|
||||
for _, arg := range args {
|
||||
if arg == "--exec-profile" {
|
||||
t.Fatalf("no profile configured, but args carry --exec-profile: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The configured profile must reach the runner's RunContext and be stamped
|
||||
// into the result, so a profile A/B report is self-describing. The boundary
|
||||
// canonicalizes (case/whitespace) so equivalent postures always carry the same
|
||||
// label, and rejects unknown names so a direct library caller cannot slip an
|
||||
// unvalidated profile into a report the way the CLI (parse-time check) cannot.
|
||||
func TestRunTurnBenchStampsExecProfile(t *testing.T) {
|
||||
set := TaskSet{
|
||||
ID: "profile-suite",
|
||||
Tasks: []BenchTask{{ID: "t1", Class: "longproc", Prompt: "p"}},
|
||||
}
|
||||
var gotProfile string
|
||||
cfg := TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
ExecProfile: " FAST ",
|
||||
Iterations: 1,
|
||||
Runner: func(_ context.Context, _ BenchTask, rc RunContext) TurnTaskOutcome {
|
||||
gotProfile = rc.ExecProfile
|
||||
return TurnTaskOutcome{Passed: true, WallMs: 10, Trace: cannedTrace(10, 1, 100)}
|
||||
},
|
||||
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
|
||||
}
|
||||
result, err := RunTurnBench(context.Background(), set, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTurnBench: %v", err)
|
||||
}
|
||||
if gotProfile != "fast" {
|
||||
t.Fatalf("runner RunContext.ExecProfile = %q, want the canonical fast", gotProfile)
|
||||
}
|
||||
if result.ExecProfile != "fast" {
|
||||
t.Fatalf("result.ExecProfile = %q, want the canonical fast", result.ExecProfile)
|
||||
}
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"execProfile":"fast"`) {
|
||||
t.Fatal("published JSON must carry execProfile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTurnBenchRejectsUnknownExecProfile(t *testing.T) {
|
||||
set := TaskSet{
|
||||
ID: "profile-suite",
|
||||
Tasks: []BenchTask{{ID: "t1", Class: "longproc", Prompt: "p"}},
|
||||
}
|
||||
ran := false
|
||||
cfg := TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
ExecProfile: "blanced",
|
||||
Iterations: 1,
|
||||
Runner: func(context.Context, BenchTask, RunContext) TurnTaskOutcome {
|
||||
ran = true
|
||||
return TurnTaskOutcome{Passed: true, WallMs: 10, Trace: cannedTrace(10, 1, 100)}
|
||||
},
|
||||
}
|
||||
_, err := RunTurnBench(context.Background(), set, cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown execution profile") {
|
||||
t.Fatalf("err = %v, want an unknown-profile rejection", err)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("nothing may run under an unknown profile")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1375,3 +1376,61 @@ func TestOpenAIRequestPromptCacheKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestPreservesCacheablePrefixAcrossTurns(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
tools := []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file",
|
||||
Description: "Read a file.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
firstMessages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "stable system prompt"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "first turn"},
|
||||
}
|
||||
secondMessages := append([]zeroruntime.Message(nil), firstMessages...)
|
||||
secondMessages = append(secondMessages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "first response"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "second turn"},
|
||||
)
|
||||
|
||||
marshalBody := func(messages []zeroruntime.Message) map[string]any {
|
||||
t.Helper()
|
||||
mapped := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
PromptCacheKey: "session-stable-prefix",
|
||||
})
|
||||
data, marshalErr := json.Marshal(mapped)
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal request: %v", marshalErr)
|
||||
}
|
||||
var body map[string]any
|
||||
if unmarshalErr := json.Unmarshal(data, &body); unmarshalErr != nil {
|
||||
t.Fatalf("unmarshal request: %v", unmarshalErr)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
first := marshalBody(firstMessages)
|
||||
second := marshalBody(secondMessages)
|
||||
firstWireMessages := first["messages"].([]any)
|
||||
secondWireMessages := second["messages"].([]any)
|
||||
if !reflect.DeepEqual(secondWireMessages[:len(firstWireMessages)], firstWireMessages) {
|
||||
t.Fatalf("first wire messages must be an exact prefix of the second:\nfirst=%#v\nsecond=%#v", firstWireMessages, secondWireMessages)
|
||||
}
|
||||
if !reflect.DeepEqual(first["tools"], second["tools"]) {
|
||||
t.Fatalf("wire tool definitions drifted:\nfirst=%#v\nsecond=%#v", first["tools"], second["tools"])
|
||||
}
|
||||
if first["prompt_cache_key"] != second["prompt_cache_key"] || first["prompt_cache_key"] != "session-stable-prefix" {
|
||||
t.Fatalf("wire prompt cache key must remain stable: first=%#v second=%#v", first["prompt_cache_key"], second["prompt_cache_key"])
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
if resumeID != "" || options.ResumeLatest {
|
||||
sessionID := resumeID
|
||||
if sessionID == "" && options.ResumeLatest {
|
||||
latest, err := store.Latest()
|
||||
latest, err := store.LatestResumable()
|
||||
if err != nil {
|
||||
return PreparedExec{}, err
|
||||
}
|
||||
@@ -105,6 +105,9 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
if session == nil {
|
||||
return PreparedExec{}, ExecError{"Zero session not found: " + sessionID}
|
||||
}
|
||||
if !IsResumableKind(session.SessionKind) {
|
||||
return PreparedExec{}, ExecError{"Zero session is not resumable: " + sessionID}
|
||||
}
|
||||
contextEvents, err := readExecContextEvents(store, session.SessionID)
|
||||
if err != nil {
|
||||
return PreparedExec{}, err
|
||||
|
||||
+20
-10
@@ -52,6 +52,7 @@ type SessionKind string
|
||||
const (
|
||||
SessionKindFork SessionKind = "fork"
|
||||
SessionKindChild SessionKind = "child"
|
||||
SessionKindSide SessionKind = "side"
|
||||
SessionKindSpecDraft SessionKind = "spec-draft"
|
||||
SessionKindSpecImpl SessionKind = "spec-impl"
|
||||
)
|
||||
@@ -125,11 +126,13 @@ type CreateInput struct {
|
||||
}
|
||||
|
||||
type ForkInput struct {
|
||||
SessionID string
|
||||
Title string
|
||||
Cwd string
|
||||
ModelID string
|
||||
Provider string
|
||||
SessionID string
|
||||
SessionKind SessionKind
|
||||
Title string
|
||||
Cwd string
|
||||
ModelID string
|
||||
Provider string
|
||||
Tag string
|
||||
}
|
||||
|
||||
type ChildInput struct {
|
||||
@@ -354,10 +357,9 @@ func (store *Store) Latest() (*Metadata, error) {
|
||||
}
|
||||
|
||||
// IsResumableKind reports whether a session kind represents a standalone,
|
||||
// user-resumable conversation rather than an agent sub-run. Regular ("") and
|
||||
// user fork sessions are resumable; child (specialist/sub-agent) and spec
|
||||
// draft/impl sessions are not — each agent task and /spec run creates one, so
|
||||
// listing them in the resume picker floods it with non-conversation entries.
|
||||
// user-resumable conversation rather than a transient or agent-owned sub-run.
|
||||
// Regular ("") and user fork sessions are resumable; side, child, and spec
|
||||
// sessions are not, so they do not flood the resume picker.
|
||||
func IsResumableKind(kind SessionKind) bool {
|
||||
switch kind {
|
||||
case "", SessionKindFork:
|
||||
@@ -418,13 +420,21 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if title == "" && parent.Title != "" {
|
||||
title = parent.Title + " (fork)"
|
||||
}
|
||||
kind := input.SessionKind
|
||||
if kind == "" {
|
||||
kind = SessionKindFork
|
||||
}
|
||||
if kind != SessionKindFork && kind != SessionKindSide {
|
||||
return Metadata{}, fmt.Errorf("invalid zero fork session kind %q", kind)
|
||||
}
|
||||
fork, err := store.Create(CreateInput{
|
||||
SessionID: input.SessionID,
|
||||
SessionKind: SessionKindFork,
|
||||
SessionKind: kind,
|
||||
Title: title,
|
||||
Cwd: firstNonEmpty(input.Cwd, parent.Cwd),
|
||||
ModelID: firstNonEmpty(input.ModelID, parent.ModelID),
|
||||
Provider: firstNonEmpty(input.Provider, parent.Provider),
|
||||
Tag: input.Tag,
|
||||
ParentSessionID: parent.SessionID,
|
||||
RootSessionID: firstNonEmpty(parent.RootSessionID, parent.SessionID),
|
||||
ForkedFromEventID: last.ID,
|
||||
|
||||
@@ -181,6 +181,32 @@ func TestStoreForkCopiesEventsAndLineage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreForkSupportsNonResumableSideSession(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir()})
|
||||
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create parent: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(parent.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": "context"}}); err != nil {
|
||||
t.Fatalf("AppendEvent: %v", err)
|
||||
}
|
||||
|
||||
side, err := store.Fork(parent.SessionID, ForkInput{
|
||||
SessionID: "side",
|
||||
SessionKind: SessionKindSide,
|
||||
Tag: "btw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork side: %v", err)
|
||||
}
|
||||
if side.SessionKind != SessionKindSide || side.Tag != "btw" || side.ParentSessionID != parent.SessionID {
|
||||
t.Fatalf("side metadata = %#v", side)
|
||||
}
|
||||
if IsResumableKind(side.SessionKind) {
|
||||
t.Fatal("side sessions must not appear as resumable conversations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreForkSkipsUsageEvents(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T11:00:00Z")})
|
||||
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent"})
|
||||
@@ -381,6 +407,7 @@ func TestListAndLatestResumableExcludeSubRuns(t *testing.T) {
|
||||
mk(SessionKindSpecImpl, "spec-impl-1")
|
||||
newestResumable := mk("", "conversation-2") // newest standalone conversation
|
||||
mk(SessionKindChild, "child-2") // newer overall, but a sub-run
|
||||
mk(SessionKindSide, "side-1") // newer overall, but non-resumable
|
||||
|
||||
resumable, err := store.ListResumable()
|
||||
if err != nil {
|
||||
@@ -404,7 +431,7 @@ func TestListAndLatestResumableExcludeSubRuns(t *testing.T) {
|
||||
if latest != nil {
|
||||
got = latest.SessionID
|
||||
}
|
||||
t.Fatalf("LatestResumable = %s, want newest resumable %s (must skip the newer child)", got, newestResumable.SessionID)
|
||||
t.Fatalf("LatestResumable = %s, want newest resumable %s (must skip newer child and side sessions)", got, newestResumable.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +562,9 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
|
||||
if _, err := store.AppendEvent("latest", AppendEventInput{Type: EventMessage, Payload: map[string]any{"content": "previous answer"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Create(CreateInput{SessionID: "newer-side", SessionKind: SessionKindSide}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prepared, err := PrepareExec(PrepareExecOptions{Store: store, ResumeLatest: true})
|
||||
if err != nil {
|
||||
@@ -546,6 +576,9 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
|
||||
if got := FormatExecPrompt("continue", prepared); got == "continue" || !strings.Contains(got, "previous answer") {
|
||||
t.Fatalf("expected session context in prompt, got %q", got)
|
||||
}
|
||||
if _, err := PrepareExec(PrepareExecOptions{Store: store, Resume: "newer-side"}); err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: newer-side") {
|
||||
t.Fatalf("explicit side-session resume error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
|
||||
forked, err := PrepareExec(PrepareExecOptions{Store: store, Fork: "latest", SessionID: "forked"})
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -113,6 +114,9 @@ func (registry *Registry) All() []Tool {
|
||||
for _, tool := range registry.tools {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
sort.Slice(tools, func(left, right int) bool {
|
||||
return tools[left].Name() < tools[right].Name()
|
||||
})
|
||||
return tools
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -72,6 +73,23 @@ func TestCoreReadOnlyToolsExposeSafeMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAllReturnsToolsSortedByName(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
for _, name := range []string{"write_file", "bash", "read_file"} {
|
||||
registry.Register(fakePlainTool{baseTool: baseTool{name: name, description: name}})
|
||||
}
|
||||
|
||||
all := registry.All()
|
||||
got := make([]string, 0, len(all))
|
||||
for _, tool := range all {
|
||||
got = append(got, tool.Name())
|
||||
}
|
||||
want := []string{"bash", "read_file", "write_file"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("Registry.All() order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreNetworkToolsExposeSafetyMetadata(t *testing.T) {
|
||||
// web_search is only registered when a backend is configured.
|
||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||
|
||||
@@ -106,6 +106,7 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
|
||||
for _, p := range t.PrefixHashes {
|
||||
if err := enc.Encode(map[string]any{
|
||||
"type": "prefix_hash",
|
||||
"system_prompt": p.SystemPromptHash,
|
||||
"base_instructions": p.BaseInstructionsHash,
|
||||
"confirmation_policy": p.ConfirmationPolicyHash,
|
||||
"project_context": p.ProjectContextHash,
|
||||
@@ -136,6 +137,27 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, event := range t.TaskStates {
|
||||
if err := enc.Encode(map[string]any{
|
||||
"type": "task_state",
|
||||
"revision": event.Revision,
|
||||
"status": event.Status,
|
||||
"plan_pending": event.PlanPending,
|
||||
"plan_in_progress": event.PlanInProgress,
|
||||
"plan_completed": event.PlanCompleted,
|
||||
"plan_failed": event.PlanFailed,
|
||||
"tools_succeeded": event.ToolsSucceeded,
|
||||
"tools_failed": event.ToolsFailed,
|
||||
"verification_passed": event.VerificationPassed,
|
||||
"verification_failed": event.VerificationFailed,
|
||||
"verification_outcome": event.VerificationOutcome,
|
||||
"changed_file_count": event.ChangedFileCount,
|
||||
"completion_decision": event.CompletionDecision,
|
||||
"plan_parity": event.PlanParity,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -205,6 +227,15 @@ func WriteText(w io.Writer, t *TurnTrace) error {
|
||||
event.Truncated, event.Reason, event.SpillCreated)
|
||||
}
|
||||
}
|
||||
if len(t.TaskStates) > 0 {
|
||||
latest := t.TaskStates[len(t.TaskStates)-1]
|
||||
write("task state: revision=%d status=%s plan=%d/%d/%d/%d tools=%d/%d verification=%d/%d(%s) files=%d plan_parity=%s completion=%s\n",
|
||||
latest.Revision, latest.Status, latest.PlanPending, latest.PlanInProgress,
|
||||
latest.PlanCompleted, latest.PlanFailed, latest.ToolsSucceeded,
|
||||
latest.ToolsFailed, latest.VerificationPassed, latest.VerificationFailed,
|
||||
latest.VerificationOutcome, latest.ChangedFileCount, latest.PlanParity,
|
||||
latest.CompletionDecision)
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
|
||||
+25
-4
@@ -101,13 +101,14 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: not a valid NDJSON trace (no type:trace header)")
|
||||
}
|
||||
// Round-trip the seven prefix-hash fields. Missing fields are
|
||||
// Round-trip the prefix-hash fields. Missing fields are
|
||||
// accepted as empty strings (a partially-written trace from a
|
||||
// crashed emitter must not fatal a parse). The decoder tolerates
|
||||
// any shape the encoder produced and the seven keys are the
|
||||
// any shape the encoder produced and the keys are the
|
||||
// contract — adding a new field is non-breaking; renaming one
|
||||
// requires a schema version bump.
|
||||
t.PrefixHashes = append(t.PrefixHashes, PrefixHash{
|
||||
SystemPromptHash: stringField(obj, "system_prompt"),
|
||||
BaseInstructionsHash: stringField(obj, "base_instructions"),
|
||||
ConfirmationPolicyHash: stringField(obj, "confirmation_policy"),
|
||||
ProjectContextHash: stringField(obj, "project_context"),
|
||||
@@ -131,6 +132,26 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
Reason: stringField(obj, "reason"),
|
||||
SpillCreated: boolField(obj, "spill_created"),
|
||||
})
|
||||
case "task_state":
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: not a valid NDJSON trace (no type:trace header)")
|
||||
}
|
||||
t.TaskStates = append(t.TaskStates, TaskStateEvent{
|
||||
Revision: int(parseInt64(obj["revision"])),
|
||||
Status: stringField(obj, "status"),
|
||||
PlanPending: int(parseInt64(obj["plan_pending"])),
|
||||
PlanInProgress: int(parseInt64(obj["plan_in_progress"])),
|
||||
PlanCompleted: int(parseInt64(obj["plan_completed"])),
|
||||
PlanFailed: int(parseInt64(obj["plan_failed"])),
|
||||
ToolsSucceeded: int(parseInt64(obj["tools_succeeded"])),
|
||||
ToolsFailed: int(parseInt64(obj["tools_failed"])),
|
||||
VerificationPassed: int(parseInt64(obj["verification_passed"])),
|
||||
VerificationFailed: int(parseInt64(obj["verification_failed"])),
|
||||
VerificationOutcome: stringField(obj, "verification_outcome"),
|
||||
ChangedFileCount: int(parseInt64(obj["changed_file_count"])),
|
||||
CompletionDecision: stringField(obj, "completion_decision"),
|
||||
PlanParity: stringField(obj, "plan_parity"),
|
||||
})
|
||||
default:
|
||||
// Unknown event type: tolerate (forward-compat) but only after a
|
||||
// header has been seen.
|
||||
@@ -151,8 +172,8 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: non-empty input had no type:trace header")
|
||||
}
|
||||
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 && len(t.OutputBudgets) == 0 {
|
||||
return nil, errors.New("parse trace: header present but no spans, counters, or prefix hashes recovered (corrupt or truncated)")
|
||||
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 && len(t.OutputBudgets) == 0 && len(t.TaskStates) == 0 {
|
||||
return nil, errors.New("parse trace: header present but no events recovered (corrupt or truncated)")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ type Recorder struct {
|
||||
firstActionStamped bool
|
||||
}
|
||||
|
||||
// maxTaskStateEvents bounds trace growth in pathological long runs. Once full,
|
||||
// the latest aggregate replaces the tail so final state is never lost.
|
||||
const maxTaskStateEvents = 128
|
||||
|
||||
// NewRecorder returns a ready recorder. sessionID correlates with the agent
|
||||
// session (Options.SessionID); runID is a per-Run sequence; profile is an
|
||||
// optional label (e.g. "cold", "warm") for benchmark runs.
|
||||
@@ -203,6 +207,24 @@ func (r *Recorder) EmitOutputBudget(event OutputBudgetEvent) {
|
||||
r.tr.OutputBudgets = append(r.tr.OutputBudgets, event)
|
||||
}
|
||||
|
||||
// EmitTaskState records one content-free structured task snapshot. Calls retain
|
||||
// event order so revisions can be replayed alongside the tool and turn stream.
|
||||
func (r *Recorder) EmitTaskState(event TaskStateEvent) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.finished {
|
||||
return
|
||||
}
|
||||
if len(r.tr.TaskStates) < maxTaskStateEvents {
|
||||
r.tr.TaskStates = append(r.tr.TaskStates, event)
|
||||
return
|
||||
}
|
||||
r.tr.TaskStates[len(r.tr.TaskStates)-1] = event
|
||||
}
|
||||
|
||||
// Finish stamps CompletedAt, derives each span's parent (by interval
|
||||
// containment) and exclusive time, and returns a snapshot of the trace. Calling
|
||||
// Finish more than once returns the same snapshot.
|
||||
@@ -223,6 +245,7 @@ func (r *Recorder) Finish() *TurnTrace {
|
||||
snap.Counters = append([]Counter(nil), r.tr.Counters...)
|
||||
snap.PrefixHashes = append([]PrefixHash(nil), r.tr.PrefixHashes...)
|
||||
snap.OutputBudgets = append([]OutputBudgetEvent(nil), r.tr.OutputBudgets...)
|
||||
snap.TaskStates = append([]TaskStateEvent(nil), r.tr.TaskStates...)
|
||||
return &snap
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskStateTraceRoundTripContainsNoTaskContent(t *testing.T) {
|
||||
recorder := NewRecorder("session", "run", "")
|
||||
recorder.Start()
|
||||
event := TaskStateEvent{
|
||||
Revision: 4,
|
||||
Status: "active",
|
||||
PlanPending: 2,
|
||||
PlanCompleted: 1,
|
||||
ToolsSucceeded: 3,
|
||||
ToolsFailed: 1,
|
||||
VerificationPassed: 1,
|
||||
VerificationOutcome: "passed",
|
||||
ChangedFileCount: 2,
|
||||
PlanParity: "match",
|
||||
}
|
||||
recorder.EmitTaskState(event)
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := WriteNDJSON(&output, recorder.Finish()); err != nil {
|
||||
t.Fatalf("WriteNDJSON: %v", err)
|
||||
}
|
||||
if strings.Contains(output.String(), "secret objective") || strings.Contains(output.String(), "private.go") {
|
||||
t.Fatalf("task content leaked into trace: %s", output.String())
|
||||
}
|
||||
if strings.Contains(output.String(), "objective_hash") {
|
||||
t.Fatalf("trace contains a confirmable objective fingerprint: %s", output.String())
|
||||
}
|
||||
parsed, err := ReadNDJSON(strings.NewReader(output.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadNDJSON: %v", err)
|
||||
}
|
||||
if len(parsed.TaskStates) != 1 || parsed.TaskStates[0] != event {
|
||||
t.Fatalf("unexpected round trip: %#v", parsed.TaskStates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateIsDocumentedAsOptionalTraceEvent(t *testing.T) {
|
||||
for _, key := range OptionalEventKeys() {
|
||||
if key == "task_state" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("task_state missing from OptionalEventKeys")
|
||||
}
|
||||
|
||||
func TestTaskStateTraceIsBoundedAndRetainsLatestSnapshot(t *testing.T) {
|
||||
recorder := NewRecorder("session", "run", "")
|
||||
for revision := 1; revision <= maxTaskStateEvents+20; revision++ {
|
||||
recorder.EmitTaskState(TaskStateEvent{Revision: revision})
|
||||
}
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) != maxTaskStateEvents {
|
||||
t.Fatalf("task state events = %d, want cap %d", len(events), maxTaskStateEvents)
|
||||
}
|
||||
if got := events[len(events)-1].Revision; got != maxTaskStateEvents+20 {
|
||||
t.Fatalf("latest revision = %d, want %d", got, maxTaskStateEvents+20)
|
||||
}
|
||||
}
|
||||
+37
-24
@@ -57,6 +57,11 @@ const (
|
||||
CounterPrewarmAttempts = "prewarm_attempts"
|
||||
CounterPrefixStable = "prefix_stable"
|
||||
CounterPrefixDrift = "prefix_drift"
|
||||
// CounterPostureEscalations counts one-shot in-run posture escalations by
|
||||
// the execution-profile controller. Deliberately distinct from
|
||||
// model_switches: a posture escalation changes loop policy knobs, never the
|
||||
// model or session.
|
||||
CounterPostureEscalations = "posture_escalations"
|
||||
)
|
||||
|
||||
// Span is one named wall interval attributed to part of a run. Each stamp is
|
||||
@@ -98,6 +103,27 @@ type OutputBudgetEvent struct {
|
||||
SpillCreated bool `json:"spill_created"`
|
||||
}
|
||||
|
||||
// TaskStateEvent is a content-free snapshot of the run's structured task
|
||||
// projection. Objective text, plan content, tool output, file paths, and
|
||||
// confirmable content fingerprints are deliberately excluded so tracing cannot
|
||||
// become a secret-bearing side channel.
|
||||
type TaskStateEvent struct {
|
||||
Revision int `json:"revision"`
|
||||
Status string `json:"status"`
|
||||
PlanPending int `json:"plan_pending"`
|
||||
PlanInProgress int `json:"plan_in_progress"`
|
||||
PlanCompleted int `json:"plan_completed"`
|
||||
PlanFailed int `json:"plan_failed"`
|
||||
ToolsSucceeded int `json:"tools_succeeded"`
|
||||
ToolsFailed int `json:"tools_failed"`
|
||||
VerificationPassed int `json:"verification_passed"`
|
||||
VerificationFailed int `json:"verification_failed"`
|
||||
VerificationOutcome string `json:"verification_outcome,omitempty"`
|
||||
ChangedFileCount int `json:"changed_file_count"`
|
||||
CompletionDecision string `json:"completion_decision,omitempty"`
|
||||
PlanParity string `json:"plan_parity"`
|
||||
}
|
||||
|
||||
// TurnTrace is the finished record for one agent.Run. It is the value
|
||||
// emitters serialize; it is not mutated after Finish returns a snapshot.
|
||||
type TurnTrace struct {
|
||||
@@ -113,34 +139,19 @@ type TurnTrace struct {
|
||||
Counters []Counter `json:"counters"`
|
||||
PrefixHashes []PrefixHash `json:"prefix_hashes,omitempty"`
|
||||
OutputBudgets []OutputBudgetEvent `json:"output_budgets,omitempty"`
|
||||
TaskStates []TaskStateEvent `json:"task_states,omitempty"`
|
||||
}
|
||||
|
||||
// PrefixHash is one fingerprint of the prompt prefix emitted by an agent run.
|
||||
// The seven fields decompose the cacheable part of the request so a downstream
|
||||
// observer can detect which sub-component drifted turn-over-turn:
|
||||
// base_instructions (the embedded core system prompt), confirmation_policy,
|
||||
// project_context (AGENTS.md / ZERO.md chain), skills, tools, schema (tool
|
||||
// JSON schemas), and complete_prefix (SHA-256 of the canonical concatenation
|
||||
// of the other six). All hashes are hex-encoded SHA-256.
|
||||
//
|
||||
// Scope: the fingerprint covers 4 of 11 sections of buildSystemPrompt. The
|
||||
// seven sections NOT covered — modelPromptAddendum, sessionRuntimeContext,
|
||||
// approvedCommandPrefixContext, workspaceSeedContext, userGuidelines,
|
||||
// specialistDelegationContext, responseStyleContext — fire only for
|
||||
// non-default Options. For default Options (the common case), the four
|
||||
// captured substrings are the full prompt and the fingerprint is accurate.
|
||||
//
|
||||
// A run with a stable CompletePrefix across turns means the four captured
|
||||
// sub-components are byte-identical. A run where CompletePrefix changes
|
||||
// names the captured sub-component that drifted, but does NOT rule out
|
||||
// drift in the seven uncaptured sections. modelPromptAddendum in particular
|
||||
// changes on a model switch (a model_switches counter the trace already
|
||||
// emits), so a consumer correlating CompletePrefix stability with
|
||||
// cached_input_tokens must cross-check the model_switches counter to
|
||||
// disambiguate "no drift" from "drift in an uncaptured section." The fields
|
||||
// are emitted as a "prefix_hash" event in the NDJSON trace (see
|
||||
// WriteNDJSON).
|
||||
// SystemPromptHash covers the exact joined system-message content, including
|
||||
// every dynamic section. ToolsHash covers tool names and descriptions in their
|
||||
// emitted order; SchemaHash covers their canonical parameter schemas in that
|
||||
// same order. CompletePrefixHash combines those three provider-visible parts.
|
||||
// The remaining hashes retain narrower diagnostic boundaries so a trace can
|
||||
// identify common sources of system-prompt drift. All hashes are hex-encoded
|
||||
// SHA-256 and emitted as a "prefix_hash" NDJSON event.
|
||||
type PrefixHash struct {
|
||||
SystemPromptHash string `json:"system_prompt"`
|
||||
BaseInstructionsHash string `json:"base_instructions"`
|
||||
ConfirmationPolicyHash string `json:"confirmation_policy"`
|
||||
ProjectContextHash string `json:"project_context"`
|
||||
@@ -289,6 +300,8 @@ func OptionalEventKeys() []string {
|
||||
"counter:" + CounterPrewarmAttempts,
|
||||
"counter:" + CounterPrefixStable,
|
||||
"counter:" + CounterPrefixDrift,
|
||||
"counter:" + CounterPostureEscalations,
|
||||
"event:prefix_hash",
|
||||
"task_state",
|
||||
}
|
||||
}
|
||||
@@ -374,6 +374,7 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
// event type. Insertion order is preserved by the parser (no sort
|
||||
// is applied on read or write).
|
||||
r.EmitPrefixHash(PrefixHash{
|
||||
SystemPromptHash: "system1",
|
||||
BaseInstructionsHash: "b1",
|
||||
ConfirmationPolicyHash: "c1",
|
||||
ProjectContextHash: "p1",
|
||||
@@ -383,6 +384,7 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
CompletePrefixHash: "complete1",
|
||||
})
|
||||
r.EmitPrefixHash(PrefixHash{
|
||||
SystemPromptHash: "system2",
|
||||
BaseInstructionsHash: "b2",
|
||||
ConfirmationPolicyHash: "c2",
|
||||
ProjectContextHash: "p2",
|
||||
@@ -427,10 +429,10 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
if parsed.PrefixHashes[0].CompletePrefixHash != "complete1" || parsed.PrefixHashes[1].CompletePrefixHash != "complete2" {
|
||||
t.Fatalf("prefix_hash insertion order lost: got %+v want [complete1, complete2]", parsed.PrefixHashes)
|
||||
}
|
||||
if parsed.PrefixHashes[0].BaseInstructionsHash != "b1" || parsed.PrefixHashes[0].SchemaHash != "x1" {
|
||||
if parsed.PrefixHashes[0].SystemPromptHash != "system1" || parsed.PrefixHashes[0].BaseInstructionsHash != "b1" || parsed.PrefixHashes[0].SchemaHash != "x1" {
|
||||
t.Fatalf("prefix_hash sub-hashes lost on first event: got %+v", parsed.PrefixHashes[0])
|
||||
}
|
||||
if parsed.PrefixHashes[1].BaseInstructionsHash != "b2" || parsed.PrefixHashes[1].SchemaHash != "x2" {
|
||||
if parsed.PrefixHashes[1].SystemPromptHash != "system2" || parsed.PrefixHashes[1].BaseInstructionsHash != "b2" || parsed.PrefixHashes[1].SchemaHash != "x2" {
|
||||
t.Fatalf("prefix_hash sub-hashes lost on second event: got %+v", parsed.PrefixHashes[1])
|
||||
}
|
||||
}
|
||||
@@ -577,3 +579,12 @@ func TestCounterPrecisionRoundTrip(t *testing.T) {
|
||||
t.Fatalf("counter round-trip = %d, want %d (float64 precision loss)", got, big)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalEventKeysIncludePostureEscalations(t *testing.T) {
|
||||
for _, key := range OptionalEventKeys() {
|
||||
if key == "counter:"+CounterPostureEscalations {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("posture_escalations missing from OptionalEventKeys: %v", OptionalEventKeys())
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
)
|
||||
|
||||
const (
|
||||
btwSessionTag = "btw"
|
||||
// btwRunIDGap keeps the hidden parent's current run below the first side run.
|
||||
// A parent cannot start another user turn while its surface is hidden; the
|
||||
// monotonic btwRunIDSeq separately prevents collisions across BTW re-entry.
|
||||
btwRunIDGap = 1_000_000
|
||||
|
||||
btwContextBoundary = `You are in an isolated side conversation. The inherited session history is reference context only.
|
||||
Do not continue or complete earlier tasks, plans, tool calls, approvals, or requests. Only instructions submitted after this boundary are active.
|
||||
Answer questions and explore lightly. Do not modify workspace state unless the user explicitly asks for that mutation in this side conversation.
|
||||
Nothing from this side conversation will be merged into the main session.`
|
||||
)
|
||||
|
||||
// btwState keeps the main surface alive while an isolated side conversation is
|
||||
// visible. The parent model continues receiving its own run messages, but its
|
||||
// transcript and session events stay hidden until the user returns.
|
||||
type btwState struct {
|
||||
active bool
|
||||
parent *model
|
||||
sideRunIDBase int
|
||||
parentNeedsInput bool
|
||||
}
|
||||
|
||||
func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
|
||||
question = strings.TrimSpace(question)
|
||||
if m.btw.active {
|
||||
if question != "" {
|
||||
return m.appendSystemNotice("A BTW conversation is already active. Run /btw with no question to return to the main session."), nil
|
||||
}
|
||||
return m.leaveBTW()
|
||||
}
|
||||
if m.compactInFlight {
|
||||
return m.appendSystemNotice("Compaction is running. Wait for it to finish before opening a BTW conversation."), nil
|
||||
}
|
||||
if m.exiting {
|
||||
return m.appendSystemNotice("Zero is waiting for the current run to finish saving before exit. A BTW conversation cannot start now."), nil
|
||||
}
|
||||
if m.activeSession.SessionID == "" {
|
||||
return m.appendSystemNotice("Start the main session with a prompt before opening a BTW conversation."), nil
|
||||
}
|
||||
if m.sessionStore == nil {
|
||||
return m.appendSystemNotice("BTW conversation unavailable: no session store configured."), nil
|
||||
}
|
||||
|
||||
fork, err := m.sessionStore.Fork(m.activeSession.SessionID, sessions.ForkInput{
|
||||
SessionKind: sessions.SessionKindSide,
|
||||
Title: btwTitle(m.activeSession.Title),
|
||||
Cwd: m.cwd,
|
||||
ModelID: m.modelName,
|
||||
Provider: m.providerName,
|
||||
Tag: btwSessionTag,
|
||||
})
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("Could not open BTW conversation: " + err.Error()), nil
|
||||
}
|
||||
events, err := m.resumeEvents(fork.SessionID)
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("Could not load BTW conversation: " + err.Error()), nil
|
||||
}
|
||||
|
||||
// Foreground loops are session-scoped. Stop them only after the side session
|
||||
// is ready, so a failed fork cannot destroy work in the main session.
|
||||
parent := m
|
||||
clearedLoops := 0
|
||||
if updated, cleared := parent.clearLoopsForSessionSwitch(); cleared > 0 {
|
||||
parent = updated
|
||||
clearedLoops = cleared
|
||||
parent.transcript = reduceTranscript(parent.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: fmt.Sprintf("Stopped %d loop(s) before opening the BTW conversation.", cleared),
|
||||
})
|
||||
}
|
||||
parent.btw = btwState{}
|
||||
// A scrollback print that was already scheduled may acknowledge after the
|
||||
// side surface is active. The hidden model does not receive that unscoped
|
||||
// acknowledgement, so clear its print latch and rebuild on return.
|
||||
parent.printInFlight = false
|
||||
parent.flushQueue = nil
|
||||
|
||||
side := parent
|
||||
side.activeSession = fork
|
||||
side.sessionEvents = events
|
||||
side.transcript = initialTranscript()
|
||||
side.transcript = appendRow(side.transcript, rowSystem, "BTW conversation · isolated from the main session · /btw or Ctrl+C to return")
|
||||
if clearedLoops > 0 {
|
||||
side.transcript = appendRow(side.transcript, rowSystem, fmt.Sprintf("Stopped %d main-session loop(s) before opening this conversation.", clearedLoops))
|
||||
}
|
||||
side.transcript = appendTranscriptRowsDedup(side.transcript, transcriptRowsFromSessionEvents(events))
|
||||
side.printInFlight = false
|
||||
side.flushQueue = nil
|
||||
side.resetFlushFrontier("· btw conversation ·")
|
||||
side.pending = false
|
||||
side.exiting = false
|
||||
side.runCancel = nil
|
||||
side.activeRunID = 0
|
||||
side.runID = maxInt(parent.runID+btwRunIDGap, parent.btwRunIDSeq)
|
||||
side.flushRunIDs = map[int]string{}
|
||||
side.liveUsageCounts = map[int]int{}
|
||||
side.usageTracker = usage.NewTracker(usage.TrackerOptions{Registry: &side.modelCatalog, Now: side.now})
|
||||
side.lastUsage = usage.Normalized{}
|
||||
side.lastUsageSeen = false
|
||||
side.unpricedRequests = 0
|
||||
side.unpricedTokens = 0
|
||||
side.compactRequests = 0
|
||||
side.compactFrame = 0
|
||||
side.lastCompactResult = nil
|
||||
side.lastCompactError = ""
|
||||
side.turnLatencySum = 0
|
||||
side.turnLatencyCount = 0
|
||||
side.turnTTFTSum = 0
|
||||
side.turnTTFTCount = 0
|
||||
side.pendingPermission = nil
|
||||
side.pendingAskUser = nil
|
||||
side.pendingSpecReview = nil
|
||||
side.queuedMessage = ""
|
||||
side.lastPrompt = ""
|
||||
side.lastImages = nil
|
||||
side.lastImageLabels = nil
|
||||
side.lastDocuments = nil
|
||||
side.inputHistory = nil
|
||||
side.historyIdx = 0
|
||||
side.historyDraft = ""
|
||||
if question == "" {
|
||||
side.pendingImages = nil
|
||||
side.pendingImageLabels = nil
|
||||
side.pendingDocuments = nil
|
||||
}
|
||||
side.loops = nil
|
||||
side.activeLoopID = ""
|
||||
side.loopTicking = false
|
||||
side.specialists.clear()
|
||||
side.plan.clear()
|
||||
side.planDetailGen++
|
||||
side.streamingText = nil
|
||||
side.streamingReasoning = ""
|
||||
side.streamingReasoningExpanded = false
|
||||
side.clearStreamingToolCall()
|
||||
side.resetStreamingFade()
|
||||
side.btw = btwState{
|
||||
active: true,
|
||||
parent: &parent,
|
||||
sideRunIDBase: side.runID,
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return side, nil
|
||||
}
|
||||
return side.launchPrompt(question)
|
||||
}
|
||||
|
||||
func (m model) leaveBTW() (model, tea.Cmd) {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m, nil
|
||||
}
|
||||
if m.pending {
|
||||
return m.appendSystemNotice("A BTW response is still running. Press Esc twice to cancel it, or wait for it to finish before returning."), nil
|
||||
}
|
||||
if len(m.flushRunIDs) > 0 {
|
||||
return m.appendSystemNotice("The cancelled BTW response is still saving its session events. Wait for it to finish before returning."), nil
|
||||
}
|
||||
if m.compactInFlight {
|
||||
return m.appendSystemNotice("BTW compaction is still running. Wait for it to finish before returning."), nil
|
||||
}
|
||||
m, _ = m.clearLoopsForSessionSwitch()
|
||||
parent := *m.btw.parent
|
||||
parent.btwRunIDSeq = maxInt(parent.btwRunIDSeq, m.runID)
|
||||
parent.btw = btwState{}
|
||||
// A hidden parent completion may have scheduled an unscoped git-sweep result
|
||||
// that landed on the side surface. Re-run it after restoring the parent so
|
||||
// its FILES sidebar cannot stay permanently marked in-flight or stale.
|
||||
parent.gitSweepInFlight = false
|
||||
var sweepCmd tea.Cmd
|
||||
parent, sweepCmd = parent.maybeGitSweep()
|
||||
// The side surface owns its own spinner tick chain. If the hidden parent is
|
||||
// still active, explicitly restart that chain after restoring it.
|
||||
parent.spinnerTicking = false
|
||||
var spinnerCmd tea.Cmd
|
||||
if parent.pending || parent.compactInFlight || parent.doctorInFlight || parent.sidebarHasAgents() || parent.aimlapiOnboardAnimating() {
|
||||
parent.spinnerTicking = true
|
||||
spinnerCmd = parent.spinner.Tick
|
||||
}
|
||||
parent.transcript = reduceTranscript(parent.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "Returned from the isolated BTW conversation. Its messages were not added to this session.",
|
||||
})
|
||||
parent.resetFlushFrontier("· returned from btw ·")
|
||||
return parent, batchCommands(sweepCmd, spinnerCmd)
|
||||
}
|
||||
|
||||
func btwCommandUnavailable(command parsedCommand) bool {
|
||||
arg := strings.ToLower(strings.TrimSpace(command.text))
|
||||
switch command.kind {
|
||||
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop,
|
||||
commandRewind, commandCompact, commandSTTModel, commandMCP:
|
||||
return true
|
||||
case commandModel:
|
||||
return arg != "list" && arg != "ls"
|
||||
case commandProvider:
|
||||
return arg != "status"
|
||||
case commandTurns, commandProfile:
|
||||
return arg != "" && arg != "status"
|
||||
case commandTheme:
|
||||
return arg != "list"
|
||||
case commandConfig:
|
||||
return arg != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resizeBTWParent carries terminal geometry into the hidden parent. Window-size
|
||||
// messages have no run ID, so normal BTW routing intentionally leaves them on
|
||||
// the visible side surface; copying the layout fields prevents stale wrapping
|
||||
// after the parent is restored.
|
||||
func (m model) resizeBTWParent(msg tea.WindowSizeMsg) model {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m
|
||||
}
|
||||
parent := *m.btw.parent
|
||||
parent.width = msg.Width
|
||||
parent.height = msg.Height
|
||||
parent = parent.clearHover()
|
||||
parent.lineAges = nil
|
||||
parent.lastStreamActivity = parent.now()
|
||||
parent.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
|
||||
m.btw.parent = &parent
|
||||
return m
|
||||
}
|
||||
|
||||
func btwTitle(parent string) string {
|
||||
parent = strings.TrimSpace(parent)
|
||||
if parent == "" {
|
||||
return "BTW conversation"
|
||||
}
|
||||
return parent + " (BTW)"
|
||||
}
|
||||
|
||||
// routeBTWParentMessage lets a main-session run finish while the side surface is
|
||||
// active. Side run IDs start far above the captured parent's counter, so messages
|
||||
// below that boundary unambiguously belong to the hidden parent model.
|
||||
func (m model) routeBTWParentMessage(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m, nil, false
|
||||
}
|
||||
if title, ok := msg.(sessionTitleGeneratedMsg); ok {
|
||||
if title.sessionID != m.btw.parent.activeSession.SessionID {
|
||||
return m, nil, false
|
||||
}
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
runID, ok := btwMessageRunID(msg)
|
||||
if !ok || runID <= 0 || runID >= m.btw.sideRunIDBase {
|
||||
return m, nil, false
|
||||
}
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
|
||||
func (m model) routeBTWMessageToParent(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
parentNext, cmd := m.btw.parent.updateModel(msg)
|
||||
parent, ok := parentNext.(model)
|
||||
if !ok {
|
||||
return m, cmd, true
|
||||
}
|
||||
parent.btw = btwState{}
|
||||
m.btw.parent = &parent
|
||||
switch msg.(type) {
|
||||
case permissionRequestMsg, askUserRequestMsg:
|
||||
if !m.btw.parentNeedsInput {
|
||||
m.btw.parentNeedsInput = true
|
||||
m = m.appendSystemNotice("The main session needs your input. Return with /btw or Ctrl+C to respond.")
|
||||
}
|
||||
case agentResponseMsg:
|
||||
m.btw.parentNeedsInput = parent.pendingPermission != nil || parent.pendingAskUser != nil
|
||||
}
|
||||
return m, cmd, true
|
||||
}
|
||||
|
||||
func btwMessageRunID(msg tea.Msg) (int, bool) {
|
||||
switch typed := msg.(type) {
|
||||
case agentTextMsg:
|
||||
return typed.runID, true
|
||||
case agentReasoningMsg:
|
||||
return typed.runID, true
|
||||
case agentUsageMsg:
|
||||
return typed.runID, true
|
||||
case agentResponseMsg:
|
||||
return typed.runID, true
|
||||
case agentRowMsg:
|
||||
return typed.runID, true
|
||||
case toolCallStreamStartMsg:
|
||||
return typed.runID, true
|
||||
case toolCallStreamDeltaMsg:
|
||||
return typed.runID, true
|
||||
case planUpdateMsg:
|
||||
return typed.runID, true
|
||||
case specialistStartMsg:
|
||||
return typed.runID, true
|
||||
case specialistCompleteMsg:
|
||||
return typed.runID, true
|
||||
case specialistProgressMsg:
|
||||
return typed.runID, true
|
||||
case swarmSessionsMsg:
|
||||
return typed.runID, true
|
||||
case permissionRequestMsg:
|
||||
return typed.runID, true
|
||||
case askUserRequestMsg:
|
||||
return typed.runID, true
|
||||
case recapGeneratedMsg:
|
||||
return typed.runID, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func newBTWTestModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
parent, err := store.Create(sessions.CreateInput{
|
||||
SessionID: "main-session",
|
||||
Title: "Main task",
|
||||
Cwd: "/repo",
|
||||
ModelID: "test-model",
|
||||
Provider: "test-provider",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create parent session: %v", err)
|
||||
}
|
||||
for _, payload := range []map[string]any{
|
||||
{"role": "user", "content": "implement the main task"},
|
||||
{"role": "assistant", "content": "working on it"},
|
||||
} {
|
||||
if _, err := store.AppendEvent(parent.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: payload}); err != nil {
|
||||
t.Fatalf("append parent event: %v", err)
|
||||
}
|
||||
}
|
||||
loaded, err := store.Get(parent.SessionID)
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("load parent session: session=%#v err=%v", loaded, err)
|
||||
}
|
||||
events, err := store.ReadEvents(parent.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("read parent events: %v", err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: "/repo",
|
||||
ModelName: "test-model",
|
||||
ProviderName: "test-provider",
|
||||
SessionStore: store,
|
||||
})
|
||||
m.activeSession = *loaded
|
||||
m.sessionEvents = events
|
||||
m.transcript = appendTranscriptRowsDedup(initialTranscript(), transcriptRowsFromSessionEvents(events))
|
||||
return m
|
||||
}
|
||||
|
||||
func TestBTWCommandParsesInlineQuestion(t *testing.T) {
|
||||
got := parseCommand("/btw double-check the approach")
|
||||
if got.kind != commandBTW || got.text != "double-check the approach" {
|
||||
t.Fatalf("parseCommand(/btw ...) = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCreatesIsolatedForkAndReturnsWithoutMerge(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
parentID := m.activeSession.SessionID
|
||||
parentEventCount := len(m.sessionEvents)
|
||||
|
||||
side, cmd := m.handleBTWCommand("")
|
||||
if cmd != nil {
|
||||
t.Fatal("bare /btw should not start an agent run")
|
||||
}
|
||||
if !side.btw.active || side.btw.parent == nil {
|
||||
t.Fatal("expected active BTW state with saved parent")
|
||||
}
|
||||
if side.activeSession.SessionKind != sessions.SessionKindSide || side.activeSession.ParentSessionID != parentID {
|
||||
t.Fatalf("side metadata = %#v", side.activeSession)
|
||||
}
|
||||
if side.activeSession.Tag != btwSessionTag {
|
||||
t.Fatalf("side tag = %q, want %q", side.activeSession.Tag, btwSessionTag)
|
||||
}
|
||||
if len(side.sessionEvents) <= parentEventCount {
|
||||
t.Fatalf("side should contain copied context plus fork marker: got %d events", len(side.sessionEvents))
|
||||
}
|
||||
if !strings.Contains(side.sessionPrompt("question"), btwContextBoundary) {
|
||||
t.Fatal("side prompt is missing the inherited-context boundary")
|
||||
}
|
||||
|
||||
updated, err := side.appendSessionEvent(sessions.EventMessage, map[string]any{
|
||||
"role": "assistant", "content": "side-only answer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("append side event: %v", err)
|
||||
}
|
||||
returned, _ := updated.leaveBTW()
|
||||
if returned.activeSession.SessionID != parentID {
|
||||
t.Fatalf("returned session = %q, want %q", returned.activeSession.SessionID, parentID)
|
||||
}
|
||||
if len(returned.sessionEvents) != parentEventCount {
|
||||
t.Fatalf("side events merged into parent: got %d events, want %d", len(returned.sessionEvents), parentEventCount)
|
||||
}
|
||||
parentEvents, err := returned.sessionStore.ReadEvents(parentID)
|
||||
if err != nil {
|
||||
t.Fatalf("read parent after return: %v", err)
|
||||
}
|
||||
for _, event := range parentEvents {
|
||||
if strings.Contains(string(event.Payload), "side-only answer") {
|
||||
t.Fatal("side-only event was persisted into the parent session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCanOpenWhileParentRunContinues(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.pending = true
|
||||
m.runID = 7
|
||||
m.activeRunID = 7
|
||||
|
||||
side, _ := m.handleBTWCommand("")
|
||||
if side.pending || side.activeRunID != 0 {
|
||||
t.Fatalf("side inherited parent run state: pending=%v activeRunID=%d", side.pending, side.activeRunID)
|
||||
}
|
||||
if side.btw.parent == nil || !side.btw.parent.pending || side.btw.parent.activeRunID != 7 {
|
||||
t.Fatalf("parent run was not preserved: %#v", side.btw.parent)
|
||||
}
|
||||
|
||||
routed, _, ok := side.routeBTWParentMessage(agentTextMsg{runID: 7, delta: "main progress"})
|
||||
if !ok {
|
||||
t.Fatal("parent run message was not routed")
|
||||
}
|
||||
if strings.Contains(routed.streamingTextString(), "main progress") {
|
||||
t.Fatal("parent streaming output leaked into the side transcript")
|
||||
}
|
||||
if routed.btw.parent == nil || !strings.Contains(routed.btw.parent.streamingTextString(), "main progress") {
|
||||
t.Fatal("parent streaming output was not retained on the hidden parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWInlineQuestionStartsSideRun(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.provider = &fakeProvider{}
|
||||
|
||||
side, cmd := m.handleBTWCommand("double-check this assumption")
|
||||
if cmd == nil || !side.pending {
|
||||
t.Fatalf("inline /btw question did not start a run: pending=%v cmd=%v", side.pending, cmd)
|
||||
}
|
||||
if side.lastPrompt != "double-check this assumption" {
|
||||
t.Fatalf("side last prompt = %q", side.lastPrompt)
|
||||
}
|
||||
if side.btw.parent == nil || side.btw.parent.pending {
|
||||
t.Fatal("idle parent should remain idle while the side run starts")
|
||||
}
|
||||
parentEvents, err := side.sessionStore.ReadEvents(side.btw.parent.activeSession.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("read parent events: %v", err)
|
||||
}
|
||||
for _, event := range parentEvents {
|
||||
if strings.Contains(string(event.Payload), "double-check this assumption") {
|
||||
t.Fatal("inline side question was written into the parent session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWRejectsBeforeMainSessionStarts(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{SessionStore: sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})})
|
||||
got, cmd := m.handleBTWCommand("")
|
||||
if cmd != nil {
|
||||
t.Fatal("pre-session /btw should not start work")
|
||||
}
|
||||
if got.btw.active || !transcriptContains(got.transcript, "Start the main session") {
|
||||
t.Fatalf("unexpected pre-session /btw result: active=%v transcript=%#v", got.btw.active, got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWBlocksCommandsThatWouldReplaceItsSession(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
updated, _ := side.dispatchCommand(parseCommand("/new"))
|
||||
got := updated.(model)
|
||||
if !got.btw.active || got.activeSession.SessionKind != sessions.SessionKindSide {
|
||||
t.Fatalf("/new replaced the active BTW session: active=%v metadata=%#v", got.btw.active, got.activeSession)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "unavailable in a BTW conversation") {
|
||||
t.Fatalf("missing blocked-command guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWBlocksPersistentConfigurationCommands(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/model other",
|
||||
"/provider add",
|
||||
"/turns 100",
|
||||
"/profile fast",
|
||||
"/theme dark",
|
||||
"/config recaps off",
|
||||
"/stt-model",
|
||||
"/mcp",
|
||||
"/rewind",
|
||||
"/compact",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
updated, _ := side.dispatchCommand(parseCommand(input))
|
||||
got := updated.(model)
|
||||
if !got.btw.active || got.activeSession.SessionKind != sessions.SessionKindSide {
|
||||
t.Fatalf("%s escaped the active BTW session: active=%v metadata=%#v", input, got.btw.active, got.activeSession)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "unavailable in a BTW conversation") {
|
||||
t.Fatalf("%s missing blocked-command guidance: %#v", input, got.transcript)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWAllowsReadOnlyConfigurationCommands(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/model list",
|
||||
"/provider status",
|
||||
"/turns",
|
||||
"/profile status",
|
||||
"/theme list",
|
||||
"/config",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
updated, _ := side.dispatchCommand(parseCommand(input))
|
||||
got := updated.(model)
|
||||
if transcriptContains(got.transcript, "unavailable in a BTW conversation") {
|
||||
t.Fatalf("%s was blocked even though it is read-only", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWExitBlockedWhileParentRunActive(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
updated, cmd := side.dispatchCommand(parseCommand("/exit"))
|
||||
got := updated.(model)
|
||||
if cmd != nil || got.exiting || !got.btw.active {
|
||||
t.Fatalf("/exit escaped BTW while parent was active: cmd=%v exiting=%v active=%v", cmd, got.exiting, got.btw.active)
|
||||
}
|
||||
if got.btw.parent == nil || !got.btw.parent.pending {
|
||||
t.Fatalf("hidden parent run was not preserved: %#v", got.btw.parent)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "main session is still running") {
|
||||
t.Fatalf("missing active-parent exit guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWReturnRestartsParentSpinner(t *testing.T) {
|
||||
for _, reducedMotion := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "animated", true: "reduced-motion"}[reducedMotion], func(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.reducedMotion = reducedMotion
|
||||
m.pending = true
|
||||
m.spinnerTicking = true
|
||||
side, _ := m.handleBTWCommand("")
|
||||
side.spinnerTicking = false
|
||||
|
||||
returned, cmd := side.leaveBTW()
|
||||
if !returned.pending || !returned.spinnerTicking {
|
||||
t.Fatalf("parent spinner was not restarted: pending=%v ticking=%v", returned.pending, returned.spinnerTicking)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("returning to an active parent did not schedule a spinner tick")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCancelledRunFlushesBeforeReturnAndRunIDsStayUnique(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.provider = &fakeProvider{}
|
||||
|
||||
side1, _ := m.handleBTWCommand("first side question")
|
||||
firstRunID := side1.activeRunID
|
||||
side1.cancelRun()
|
||||
blocked, _ := side1.leaveBTW()
|
||||
if !blocked.btw.active || !transcriptContains(blocked.transcript, "still saving its session events") {
|
||||
t.Fatal("BTW returned before the cancelled side run finished flushing")
|
||||
}
|
||||
|
||||
flushedModel, _ := side1.updateModel(agentResponseMsg{runID: firstRunID})
|
||||
flushed := flushedModel.(model)
|
||||
if len(flushed.flushRunIDs) != 0 {
|
||||
t.Fatalf("cancelled side run did not drain: %#v", flushed.flushRunIDs)
|
||||
}
|
||||
parent, _ := flushed.leaveBTW()
|
||||
side2, _ := parent.handleBTWCommand("second side question")
|
||||
if side2.activeRunID == firstRunID {
|
||||
t.Fatalf("BTW run ID was reused across re-entry: %d", firstRunID)
|
||||
}
|
||||
|
||||
updated, _ := side2.updateModel(agentResponseMsg{
|
||||
runID: firstRunID,
|
||||
rows: []transcriptRow{{kind: rowAssistant, text: "answer to the FIRST question"}},
|
||||
})
|
||||
got := updated.(model)
|
||||
if !got.pending || transcriptContains(got.transcript, "answer to the FIRST question") {
|
||||
t.Fatal("a stale side response hijacked the next BTW run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWRejectsExplicitResumeOfSideSession(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
if _, err := m.resolveResumeSession(side.activeSession.SessionID); err == nil || !strings.Contains(err.Error(), "not resumable") {
|
||||
t.Fatalf("explicit side-session resume error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCannotStartDuringDeferredExit(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.exiting = true
|
||||
|
||||
got, cmd := m.handleBTWCommand("should not run")
|
||||
if cmd != nil || got.btw.active {
|
||||
t.Fatalf("BTW started during deferred exit: active=%v cmd=%v", got.btw.active, cmd)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "cannot start now") {
|
||||
t.Fatalf("missing deferred-exit guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWStatusAndHelpStayVisible(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
if status := plainRender(t, side.statusLine(100)); !strings.Contains(status, "BTW") {
|
||||
t.Fatalf("status line has no persistent BTW indicator: %q", status)
|
||||
}
|
||||
groups := side.buildKeybindingGroups()
|
||||
if got := groups[0].bindings[3].desc; !strings.Contains(got, "return to the main session") {
|
||||
t.Fatalf("BTW Ctrl+C help = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWResizeUpdatesHiddenParent(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.width = 80
|
||||
m.height = 24
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
updated, _ := side.updateModel(tea.WindowSizeMsg{Width: 120, Height: 50})
|
||||
got := updated.(model)
|
||||
if got.btw.parent == nil || got.btw.parent.width != 120 || got.btw.parent.height != 50 {
|
||||
t.Fatalf("hidden parent kept stale geometry: %#v", got.btw.parent)
|
||||
}
|
||||
returned, _ := got.leaveBTW()
|
||||
if returned.width != 120 || returned.height != 50 {
|
||||
t.Fatalf("restored parent geometry = %dx%d, want 120x50", returned.width, returned.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWUsesIndependentUsageTracker(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
parentTracker := m.usageTracker
|
||||
side, _ := m.handleBTWCommand("")
|
||||
if side.usageTracker == nil || side.usageTracker == parentTracker {
|
||||
t.Fatal("BTW conversation shared the main session usage tracker")
|
||||
}
|
||||
returned, _ := side.leaveBTW()
|
||||
if returned.usageTracker != parentTracker {
|
||||
t.Fatal("returning from BTW did not restore the main session usage tracker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWFailedForkKeepsMainSessionLoops(t *testing.T) {
|
||||
badRoot := filepath.Join(t.TempDir(), "sessions")
|
||||
if err := os.WriteFile(badRoot, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{SessionStore: sessions.NewStore(sessions.StoreOptions{RootDir: badRoot})})
|
||||
m.activeSession = sessions.Metadata{SessionID: "main-session"}
|
||||
m.loops = []*loopState{{id: "loop-1"}}
|
||||
|
||||
got, _ := m.handleBTWCommand("")
|
||||
if len(got.loops) != 1 || got.loops[0].id != "loop-1" {
|
||||
t.Fatalf("failed BTW fork cleared main-session loops: %#v", got.loops)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "Could not open BTW conversation") {
|
||||
t.Fatalf("missing fork failure notice: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCommandReturnsToParentSession(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
parentID := m.activeSession.SessionID
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
updated, cmd := side.dispatchCommand(parseCommand("/btw"))
|
||||
returned := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("returning from an idle BTW conversation should not start work")
|
||||
}
|
||||
if returned.btw.active || returned.btw.parent != nil {
|
||||
t.Fatalf("BTW state remained active after /btw: %#v", returned.btw)
|
||||
}
|
||||
if returned.activeSession.SessionID != parentID {
|
||||
t.Fatalf("returned session = %q, want parent %q", returned.activeSession.SessionID, parentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCtrlCDuringRunDoesNotClearDraft(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
side.pending = true
|
||||
side.input.SetValue("keep this draft")
|
||||
|
||||
updated, cmd := side.handleCtrlC()
|
||||
got := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("Ctrl+C should not start a command while a BTW response is running")
|
||||
}
|
||||
if !got.btw.active {
|
||||
t.Fatal("Ctrl+C returned from BTW while its response was still running")
|
||||
}
|
||||
if got.composerValue() != "keep this draft" {
|
||||
t.Fatalf("Ctrl+C cleared the in-flight BTW draft: %q", got.composerValue())
|
||||
}
|
||||
if !transcriptContains(got.transcript, "BTW response is still running") {
|
||||
t.Fatalf("missing in-flight return guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
@@ -464,13 +464,15 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
// dedupe against the entry just persisted here, showing the same switch twice.
|
||||
config.RecentModelEntry{Provider: m.providerName, Model: target.modelID},
|
||||
)
|
||||
resetEffort := false
|
||||
if m.reasoningEffort != "" && !reasoningEffortAllowed(target.reasoningEfforts, m.reasoningEffort) {
|
||||
// Drop an unsupported carry-over preference and fall back to the
|
||||
// model's effective default for the new model.
|
||||
m.reasoningEffort = ""
|
||||
resetEffort = true
|
||||
}
|
||||
// Drop a known-unsupported preference, void any profile bookkeeping the
|
||||
// drop erased, and re-derive an active profile's per-model effort fill.
|
||||
// The ring is authoritative only for catalog-resolved targets
|
||||
// (target.entry non-nil); live-discovered/custom targets carry no support
|
||||
// knowledge, so an explicit preference survives onto them. resetEffort
|
||||
// reports a dropped preference nothing refilled (shown as a reset to
|
||||
// auto).
|
||||
var resetEffort bool
|
||||
m, resetEffort = m.reconcileEffortForModelSwitch(target.reasoningEfforts, target.entry != nil)
|
||||
effortLine := "effort: " + m.effortDisplay()
|
||||
if resetEffort {
|
||||
// Preference was dropped: show "auto" (model default applies), not a
|
||||
@@ -551,6 +553,13 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
m.providerProfile = target
|
||||
m.providerName = target.Name
|
||||
m.modelName = target.Model
|
||||
// An active profile's effort fill is per-model: re-derive it for the
|
||||
// destination, exactly like handleModelCommand does. No generic
|
||||
// unsupported-drop here: cross-provider targets are often custom models
|
||||
// the catalog cannot vouch for either way, so an explicit preference is
|
||||
// carried (pre-existing behavior) while the profile's own fill stays
|
||||
// conservative — it only ever applies where support is known.
|
||||
m = m.reconcileProfileAfterModelSwitch(m.availableReasoningEfforts())
|
||||
// Record the outgoing pair too — see the matching comment in
|
||||
// handleModelCommand for why (keeps the session's starting model from
|
||||
// silently dropping out of "Recent" on the first switch away from it).
|
||||
|
||||
@@ -41,11 +41,13 @@ const (
|
||||
commandAddDir
|
||||
commandSelfCorrect
|
||||
commandTurns
|
||||
commandProfile
|
||||
commandRetry
|
||||
commandEdit
|
||||
commandCopy
|
||||
commandExport
|
||||
commandNew
|
||||
commandBTW
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandVoice
|
||||
@@ -191,6 +193,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Start a fresh session; the current one stays resumable via /resume.",
|
||||
kind: commandNew,
|
||||
},
|
||||
{
|
||||
name: "/btw",
|
||||
usage: "/btw [question]",
|
||||
group: commandGroupSession,
|
||||
description: "Open an isolated side conversation; run /btw again to return.",
|
||||
kind: commandBTW,
|
||||
},
|
||||
{
|
||||
name: "/search",
|
||||
aliases: []string{"/find"},
|
||||
@@ -286,6 +295,13 @@ 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: "/profile",
|
||||
usage: "/profile [status|balanced|fast|thorough]",
|
||||
group: commandGroupSession,
|
||||
description: "Show or switch the execution profile for the next run (loop posture: turn budget, effort, self-correction, escalation; model selection is unchanged).",
|
||||
kind: commandProfile,
|
||||
},
|
||||
{
|
||||
name: "/retry",
|
||||
usage: "/retry",
|
||||
|
||||
@@ -31,6 +31,10 @@ type keybindingGroup struct {
|
||||
// Sourced from the real key cases in model.go (Update) — not invented. When a
|
||||
// binding is added/changed there, update this method too.
|
||||
func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
ctrlCDescription := "cancel the run, then quit"
|
||||
if m.btw.active {
|
||||
ctrlCDescription = "return to the main session when the BTW run is idle"
|
||||
}
|
||||
return []keybindingGroup{
|
||||
{
|
||||
title: "Chat",
|
||||
@@ -38,7 +42,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
{"Enter", "send the message"},
|
||||
{"Shift+Enter / Alt+Enter", "insert a newline (multi-line compose)"},
|
||||
{"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"Ctrl+C", ctrlCDescription},
|
||||
{"Ctrl+X then letter", "common slash commands (m=/model, p=/provider, r=/resume, \u2026)"},
|
||||
{"Ctrl+X ?", "list every Ctrl+X slash shortcut"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
|
||||
+78
-14
@@ -103,6 +103,11 @@ type model struct {
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
btw btwState
|
||||
// btwRunIDSeq is the highest run ID issued by any completed or abandoned BTW
|
||||
// surface. It survives returning to the parent so a late message from an old
|
||||
// side run can never match a run in a later BTW conversation.
|
||||
btwRunIDSeq int
|
||||
// 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).
|
||||
@@ -128,20 +133,36 @@ type model struct {
|
||||
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
|
||||
// Active execution profile (set by /profile; applies to the NEXT run).
|
||||
// The displaced/applied pairs let a switch or /profile balanced restore
|
||||
// exactly what the profile replaced while leaving later manual overrides
|
||||
// (/turns, Ctrl+T, /selfcorrect) alone: each knob is only reverted when it
|
||||
// still holds the value the profile applied.
|
||||
execProfileName string
|
||||
execProfileDisplacedMaxTurns int
|
||||
execProfileAppliedMaxTurns int
|
||||
execProfileAppliedEffort modelregistry.ReasoningEffort
|
||||
execProfileArmedSelfCorrect bool
|
||||
// The touched bits record explicit user choices made while a profile is
|
||||
// active (/turns, /effort, Ctrl+T, /selfcorrect); a touched knob is never
|
||||
// reverted, even when its value coincides with what the profile applied.
|
||||
execProfileTurnsTouched bool
|
||||
execProfileEffortTouched bool
|
||||
execProfileSelfCorrectTouched bool
|
||||
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.
|
||||
@@ -1013,6 +1034,14 @@ func (m model) shutdownLSPManager() {
|
||||
}
|
||||
|
||||
func (m model) handleCtrlC() (tea.Model, tea.Cmd) {
|
||||
if m.btw.active {
|
||||
if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
}
|
||||
return m.leaveBTW()
|
||||
}
|
||||
if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
@@ -1092,6 +1121,12 @@ func batchCommands(cmds ...tea.Cmd) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if size, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m = m.resizeBTWParent(size)
|
||||
}
|
||||
if next, cmd, routed := m.routeBTWParentMessage(msg); routed {
|
||||
return next, cmd
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case composerBlinkMsg:
|
||||
switch {
|
||||
@@ -4098,6 +4133,21 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
// dispatchCommand runs a parsed slash/prompt command after submit/leader
|
||||
// preamble (history, composer clear, leave-prompt disarm) has already run.
|
||||
func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
if m.btw.active && command.kind == commandExit && m.btw.parent != nil &&
|
||||
(m.btw.parent.pending || m.btw.parent.compactInFlight || len(m.btw.parent.flushRunIDs) > 0) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "The main session is still running. Return to it first with /btw or Ctrl+C before exiting.",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
if m.btw.active && btwCommandUnavailable(command) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: command.name + " is unavailable in a BTW conversation. Return to the main session first with /btw or Ctrl+C.",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
switch command.kind {
|
||||
case commandEmpty:
|
||||
return m, nil
|
||||
@@ -4136,6 +4186,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandBTW:
|
||||
return m.handleBTWCommand(command.text)
|
||||
case commandLoop:
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandExit:
|
||||
@@ -4346,6 +4398,18 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
m, text = m.handleTurnsCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandProfile:
|
||||
// Same idle-session rule as /turns: switching the profile mutates the
|
||||
// turn budget (and its ZERO_MAX_TURNS propagation), so a change needs
|
||||
// an idle session; bare /profile (status) is always allowed.
|
||||
if m.pending && strings.TrimSpace(command.text) != "" && !strings.EqualFold(strings.TrimSpace(command.text), "status") {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Profile\nFinish or stop the current run before switching the execution profile."})
|
||||
return m, nil
|
||||
}
|
||||
text := ""
|
||||
m, text = m.handleProfileCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandTheme:
|
||||
// Bare `/theme` opens the popup picker (live preview on move, apply on
|
||||
// Enter), matching /model and /effort. An explicit `/theme auto|dark|light`
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// profileSwitchModel builds a session on a model whose catalog entry supports
|
||||
// the fast profile's "low" effort, with a second saved provider whose custom
|
||||
// model has no catalog entry (so no effort ring) — the two directions the
|
||||
// model-switch reconciliation must handle.
|
||||
func profileSwitchModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
return newModel(context.Background(), Options{
|
||||
ProviderName: "anthropic",
|
||||
ModelName: "claude-sonnet-4.5",
|
||||
Provider: &fakeProvider{},
|
||||
ProviderProfile: config.ProviderProfile{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"},
|
||||
SavedProviders: []config.ProviderProfile{
|
||||
{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"},
|
||||
{Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"},
|
||||
},
|
||||
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &fakeProvider{}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The profile's effort fill is per-model and must be re-derived on a model
|
||||
// switch: dropped when the destination does not support the level, refilled
|
||||
// when a later destination does, with the escalation's effort restore tracking
|
||||
// whether the profile currently governs the effort.
|
||||
func TestProfileEffortReconciledOnModelSwitch(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
if m.reasoningEffort != "low" || m.agentOptions.Profile == nil || !m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatalf("fast on a low-supporting model must fill low and arm the restore, got effort %q", m.reasoningEffort)
|
||||
}
|
||||
|
||||
// Supported -> unsupported: the profile-applied level must not survive
|
||||
// onto a model with no effort ring.
|
||||
m, text, ok, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
|
||||
if !ok {
|
||||
t.Fatalf("switch to ollama failed: %q", text)
|
||||
}
|
||||
if m.reasoningEffort != "" || m.execProfileAppliedEffort != "" {
|
||||
t.Fatalf("effort = %q applied = %q, want both cleared on an unsupported destination", m.reasoningEffort, m.execProfileAppliedEffort)
|
||||
}
|
||||
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("the profile no longer governs the effort, so the restore must disarm")
|
||||
}
|
||||
if m.execProfileName != "fast" || m.agentOptions.MaxTurns != 30 {
|
||||
t.Fatalf("profile must stay active across the switch: name %q turns %d", m.execProfileName, m.agentOptions.MaxTurns)
|
||||
}
|
||||
|
||||
// Unsupported -> supported: switching (back) to a supporting model must
|
||||
// behave like selecting the profile there.
|
||||
m, text, ok, _ = m.switchProviderModel("anthropic", "claude-sonnet-4.5")
|
||||
if !ok {
|
||||
t.Fatalf("switch back to anthropic failed: %q", text)
|
||||
}
|
||||
if m.reasoningEffort != "low" || m.execProfileAppliedEffort != "low" {
|
||||
t.Fatalf("effort = %q applied = %q, want the profile's low refilled", m.reasoningEffort, m.execProfileAppliedEffort)
|
||||
}
|
||||
if !m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("the profile governs the effort again, so the restore must re-arm")
|
||||
}
|
||||
}
|
||||
|
||||
// The direct /model path (reconcileEffortForModelSwitch, driven by the
|
||||
// target's authoritative ring) drops a KNOWN-unsupported effort — the TUI run
|
||||
// path forwards the effort unfiltered, so it must not survive. When that drop
|
||||
// voids a choice made under an active profile, the profile's effort
|
||||
// bookkeeping resets and the profile resumes governing: a later switch to a
|
||||
// supporting ring fills the profile's level again.
|
||||
func TestProfileEffortVoidedByModelSwitchDrop(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
if m.reasoningEffort != "high" || !m.execProfileEffortTouched {
|
||||
t.Fatalf("setup: effort %q touched %v, want an explicit high", m.reasoningEffort, m.execProfileEffortTouched)
|
||||
}
|
||||
|
||||
// Destination with an AUTHORITATIVE empty ring (a catalog model without
|
||||
// effort controls): "high" is known-unsupported, the drop fires, and the
|
||||
// voided choice resets the profile's bookkeeping.
|
||||
m, reset := m.reconcileEffortForModelSwitch(nil, true)
|
||||
if !reset {
|
||||
t.Fatal("a dropped preference with nothing refilled must report the reset")
|
||||
}
|
||||
if m.reasoningEffort != "" {
|
||||
t.Fatalf("effort = %q, a known-unsupported level must not survive", m.reasoningEffort)
|
||||
}
|
||||
if m.execProfileEffortTouched || m.execProfileAppliedEffort != "" {
|
||||
t.Fatalf("voided choice must reset the bookkeeping: touched %v applied %q", m.execProfileEffortTouched, m.execProfileAppliedEffort)
|
||||
}
|
||||
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("no profile-governed effort on this model, so the restore must be disarmed")
|
||||
}
|
||||
if m.execProfileName != "fast" || m.agentOptions.MaxTurns != 30 {
|
||||
t.Fatalf("profile must stay active: name %q turns %d", m.execProfileName, m.agentOptions.MaxTurns)
|
||||
}
|
||||
|
||||
// With the void cleared, a destination that supports the profile's level
|
||||
// behaves like selecting the profile there: the fill returns.
|
||||
ring := []modelregistry.ReasoningEffort{"low", "medium", "high"}
|
||||
m, reset = m.reconcileEffortForModelSwitch(ring, true)
|
||||
if reset {
|
||||
t.Fatal("a refill is not a reset to auto")
|
||||
}
|
||||
if m.reasoningEffort != "low" || m.execProfileAppliedEffort != "low" {
|
||||
t.Fatalf("effort = %q applied = %q, want the profile's low refilled", m.reasoningEffort, m.execProfileAppliedEffort)
|
||||
}
|
||||
if !m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("the profile governs the effort again, so the restore must re-arm")
|
||||
}
|
||||
}
|
||||
|
||||
// A touched effort the destination DOES support survives the direct /model
|
||||
// path untouched — the drop only fires for known-unsupported levels.
|
||||
func TestProfileEffortTouchedSurvivesSupportedModelSwitch(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
m, reset := m.reconcileEffortForModelSwitch([]modelregistry.ReasoningEffort{"low", "medium", "high"}, true)
|
||||
if reset {
|
||||
t.Fatal("nothing was dropped, so no reset")
|
||||
}
|
||||
if m.reasoningEffort != "high" || !m.execProfileEffortTouched {
|
||||
t.Fatalf("effort = %q touched %v, an explicit choice the destination supports must survive", m.reasoningEffort, m.execProfileEffortTouched)
|
||||
}
|
||||
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("an explicit choice keeps the restore disarmed")
|
||||
}
|
||||
}
|
||||
|
||||
// An UNKNOWN ring (live-discovered/custom target with no catalog entry) is
|
||||
// not evidence of unsupported: an explicit preference survives, exactly as it
|
||||
// does on the cross-provider picker path, while the profile's own fill still
|
||||
// retreats to models where support is known.
|
||||
func TestProfileEffortUnknownRingPreservesExplicitChoice(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
m, reset := m.reconcileEffortForModelSwitch(nil, false)
|
||||
if reset {
|
||||
t.Fatal("an unknown ring must not report a reset")
|
||||
}
|
||||
if m.reasoningEffort != "high" || !m.execProfileEffortTouched {
|
||||
t.Fatalf("effort = %q touched %v, an explicit choice must survive an unknown ring", m.reasoningEffort, m.execProfileEffortTouched)
|
||||
}
|
||||
|
||||
// Untouched profile fill on an unknown ring: the fill retreats (the
|
||||
// profile only governs where support is known), with clean bookkeeping.
|
||||
m2 := profileSwitchModel(t)
|
||||
m2, _ = m2.handleProfileCommand("fast")
|
||||
m2, reset = m2.reconcileEffortForModelSwitch(nil, false)
|
||||
if reset {
|
||||
t.Fatal("a profile-fill retreat is not an unsupported-preference reset")
|
||||
}
|
||||
if m2.reasoningEffort != "" || m2.execProfileAppliedEffort != "" {
|
||||
t.Fatalf("effort = %q applied = %q, the profile fill must retreat on an unknown ring", m2.reasoningEffort, m2.execProfileAppliedEffort)
|
||||
}
|
||||
if m2.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("no profile-governed effort, so the restore must be disarmed")
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly touched effort is the user's choice: the reconciliation must
|
||||
// leave it alone entirely across model switches.
|
||||
func TestProfileEffortTouchedSurvivesModelSwitch(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
m, text, ok, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
|
||||
if !ok {
|
||||
t.Fatalf("switch to ollama failed: %q", text)
|
||||
}
|
||||
if m.reasoningEffort != "high" {
|
||||
t.Fatalf("effort = %q, an explicit choice must survive the switch untouched", m.reasoningEffort)
|
||||
}
|
||||
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("an explicit choice keeps the restore disarmed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileCommandStatus(t *testing.T) {
|
||||
_, out := model{}.handleProfileCommand("")
|
||||
if !strings.Contains(out, "balanced (default)") {
|
||||
t.Fatalf("bare /profile must report the balanced default, got %q", out)
|
||||
}
|
||||
_, out = model{}.handleProfileCommand("status")
|
||||
if !strings.Contains(out, "balanced (default)") {
|
||||
t.Fatalf("/profile status must report the balanced default, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileCommandUnknownValue(t *testing.T) {
|
||||
got, out := model{}.handleProfileCommand("turbo")
|
||||
if !strings.Contains(out, "Usage:") {
|
||||
t.Fatalf("unknown profile must print usage, got %q", out)
|
||||
}
|
||||
if got.execProfileName != "" || got.agentOptions.Profile != nil {
|
||||
t.Fatal("unknown profile must not change any state")
|
||||
}
|
||||
}
|
||||
|
||||
// Fast applies to the next run: the turn budget drops, the escalation policy
|
||||
// is armed with the DISPLACED budget as its restore target, and effort stays
|
||||
// untouched when the session has no model-supported effort ring (model{}).
|
||||
func TestProfileCommandSetsFastPosture(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
got, out := m.handleProfileCommand("fast")
|
||||
if got.agentOptions.MaxTurns != 30 {
|
||||
t.Fatalf("MaxTurns = %d, want the fast profile's 30", got.agentOptions.MaxTurns)
|
||||
}
|
||||
if got.execProfileName != "fast" {
|
||||
t.Fatalf("execProfileName = %q, want fast", got.execProfileName)
|
||||
}
|
||||
policy := got.agentOptions.Profile
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("fast must arm an escalation policy on agentOptions")
|
||||
}
|
||||
if policy.Escalate.MaxTurns != 80 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, want the displaced 80", policy.Escalate.MaxTurns)
|
||||
}
|
||||
if got.reasoningEffort != "" {
|
||||
t.Fatalf("effort = %q, must stay auto when the model exposes no effort ring", got.reasoningEffort)
|
||||
}
|
||||
if !strings.Contains(out, "fast") || !strings.Contains(out, "headless-only") {
|
||||
t.Fatalf("status must name the profile and the TUI signal asymmetry, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileCommandThoroughArmsSelfCorrect(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
got, _ := m.handleProfileCommand("thorough")
|
||||
if got.agentOptions.MaxTurns != 160 {
|
||||
t.Fatalf("MaxTurns = %d, want thorough's 160", got.agentOptions.MaxTurns)
|
||||
}
|
||||
if !got.selfCorrectTests {
|
||||
t.Fatal("thorough must arm full self-correction")
|
||||
}
|
||||
if got.agentOptions.Profile != nil {
|
||||
t.Fatal("thorough has no escalation triggers, so the loop policy must stay nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileCommandBalancedRestoresDisplaced(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
got, _ := m.handleProfileCommand("balanced")
|
||||
if got.agentOptions.MaxTurns != 80 {
|
||||
t.Fatalf("MaxTurns = %d, want the displaced 80 restored", got.agentOptions.MaxTurns)
|
||||
}
|
||||
if got.execProfileName != "" || got.agentOptions.Profile != nil {
|
||||
t.Fatal("balanced must clear the profile state and loop policy")
|
||||
}
|
||||
}
|
||||
|
||||
// Profiles must not stack: switching fast -> thorough restores fast's
|
||||
// displacement first, so thorough displaces the ORIGINAL budget and a later
|
||||
// balanced lands back on it.
|
||||
func TestProfileCommandSwitchDoesNotStack(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleProfileCommand("thorough")
|
||||
if m.agentOptions.MaxTurns != 160 {
|
||||
t.Fatalf("MaxTurns = %d, want thorough's 160", m.agentOptions.MaxTurns)
|
||||
}
|
||||
if m.execProfileDisplacedMaxTurns != 80 {
|
||||
t.Fatalf("displaced = %d, want the original 80 (not fast's 30)", m.execProfileDisplacedMaxTurns)
|
||||
}
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.agentOptions.MaxTurns != 80 {
|
||||
t.Fatalf("MaxTurns = %d, want the original 80 restored", m.agentOptions.MaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit /turns while a profile is active is a pinned budget: it must
|
||||
// disarm the escalation's turn target (mirroring headless exec, where an
|
||||
// explicit --max-turns leaves nothing displaced) while the other triggers
|
||||
// stay armed, and it must survive a later revert.
|
||||
func TestProfileCommandTurnsPinDisarmsEscalationTarget(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleTurnsCommand("20")
|
||||
if m.agentOptions.MaxTurns != 20 {
|
||||
t.Fatalf("MaxTurns = %d, want the pinned 20", m.agentOptions.MaxTurns)
|
||||
}
|
||||
policy := m.agentOptions.Profile
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("the profile's other escalation triggers must stay armed")
|
||||
}
|
||||
if policy.Escalate.MaxTurns != 0 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, want 0 (a pinned budget must never be raised by escalation)", policy.Escalate.MaxTurns)
|
||||
}
|
||||
if policy.Escalate.OnToolFailureStreak == 0 {
|
||||
t.Fatal("disarming the turn target must not disarm the other triggers")
|
||||
}
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.agentOptions.MaxTurns != 20 {
|
||||
t.Fatalf("MaxTurns = %d, the pinned 20 must survive the revert", m.agentOptions.MaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
// A manual override that COINCIDES with the profile's own value must still
|
||||
// survive the revert: touched beats value equality.
|
||||
func TestProfileCommandRevertLeavesCoincidingOverride(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleTurnsCommand("30") // explicit, happens to equal fast's 30
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.agentOptions.MaxTurns != 30 {
|
||||
t.Fatalf("MaxTurns = %d, the explicit /turns 30 must survive even though it equals fast's value", m.agentOptions.MaxTurns)
|
||||
}
|
||||
|
||||
// Same for self-correct: /selfcorrect off then on under thorough is an
|
||||
// explicit opt-in, not the profile's arming.
|
||||
m2 := model{}
|
||||
m2.agentOptions.MaxTurns = 80
|
||||
m2, _ = m2.handleProfileCommand("thorough")
|
||||
m2, _ = m2.handleSelfCorrectCommand("off")
|
||||
m2, _ = m2.handleSelfCorrectCommand("on")
|
||||
m2, _ = m2.handleProfileCommand("balanced")
|
||||
if !m2.selfCorrectTests {
|
||||
t.Fatal("an explicit /selfcorrect on must survive the revert even though thorough also armed it")
|
||||
}
|
||||
}
|
||||
|
||||
// Reverting a profile whose displaced budget was zero (nothing was set before
|
||||
// it) must clear ZERO_MAX_TURNS: SetMaxTurnsEnv ignores zero, so without an
|
||||
// explicit unset, spawned sub-agents would keep the removed profile's budget.
|
||||
func TestProfileCommandRevertFromZeroClearsMaxTurnsEnv(t *testing.T) {
|
||||
t.Setenv(config.MaxTurnsEnv, "")
|
||||
m := model{} // MaxTurns 0: the profile displaces nothing
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
if got := os.Getenv(config.MaxTurnsEnv); got != "30" {
|
||||
t.Fatalf("env after apply = %q, want the profile's 30", got)
|
||||
}
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.agentOptions.MaxTurns != 0 {
|
||||
t.Fatalf("MaxTurns = %d, want the displaced 0 restored", m.agentOptions.MaxTurns)
|
||||
}
|
||||
if got, present := os.LookupEnv(config.MaxTurnsEnv); present {
|
||||
t.Fatalf("env after revert = %q (present), want the key removed", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit effort choice after /profile must disarm the escalation's
|
||||
// effort restore (the effort analog of the /turns pin): a mid-run escalation
|
||||
// must never clear an effort the user pinned by hand. Needs a catalog model
|
||||
// with an effort ring so the profile's fill actually applies.
|
||||
func TestProfileCommandExplicitEffortDisarmsRestore(t *testing.T) {
|
||||
m := model{modelName: "claude-sonnet-4.5"}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
if m.reasoningEffort != "low" {
|
||||
t.Fatalf("profile did not fill low effort for the test model: got %q", m.reasoningEffort)
|
||||
}
|
||||
policy := m.agentOptions.Profile
|
||||
if policy == nil || policy.Escalate == nil || !policy.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("profile-filled effort must arm the escalation effort restore")
|
||||
}
|
||||
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
if m.reasoningEffort != "high" {
|
||||
t.Fatalf("effort = %q, want the explicit high", m.reasoningEffort)
|
||||
}
|
||||
policy = m.agentOptions.Profile
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("the escalation policy must stay armed")
|
||||
}
|
||||
if policy.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("an explicit /effort must disarm the effort restore")
|
||||
}
|
||||
if policy.Escalate.MaxTurns != 80 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, disarming effort must not touch the turn target", policy.Escalate.MaxTurns)
|
||||
}
|
||||
// And the explicit choice survives a later revert (touched bit).
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.reasoningEffort != "high" {
|
||||
t.Fatalf("effort = %q, the explicit high must survive the revert", m.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
// A manual override made after selecting a profile survives the revert: the
|
||||
// profile only restores knobs that still hold the value it applied.
|
||||
func TestProfileCommandRevertLeavesManualOverride(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleTurnsCommand("100")
|
||||
got, _ := m.handleProfileCommand("balanced")
|
||||
if got.agentOptions.MaxTurns != 100 {
|
||||
t.Fatalf("MaxTurns = %d, the user's explicit /turns 100 must survive the revert", got.agentOptions.MaxTurns)
|
||||
}
|
||||
// An explicit /selfcorrect choice armed BEFORE thorough must survive too.
|
||||
m2 := model{selfCorrectTests: true}
|
||||
m2.agentOptions.MaxTurns = 80
|
||||
m2, _ = m2.handleProfileCommand("thorough")
|
||||
m2, _ = m2.handleProfileCommand("balanced")
|
||||
if !m2.selfCorrectTests {
|
||||
t.Fatal("an explicit self-correct opt-in must survive profile revert")
|
||||
}
|
||||
}
|
||||
@@ -246,11 +246,15 @@ func (m model) sessionPrompt(prompt string) string {
|
||||
if m.activeSession.SessionID == "" || len(m.sessionEvents) == 0 {
|
||||
return prompt
|
||||
}
|
||||
return sessions.FormatExecPrompt(prompt, sessions.PreparedExec{
|
||||
formatted := sessions.FormatExecPrompt(prompt, sessions.PreparedExec{
|
||||
Mode: sessions.ModeResume,
|
||||
Session: m.activeSession,
|
||||
ContextEvents: append([]sessions.Event{}, m.sessionEvents...),
|
||||
})
|
||||
if m.activeSession.SessionKind == sessions.SessionKindSide {
|
||||
return btwContextBoundary + "\n\n" + formatted
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
func (m model) resolveResumeSession(args string) (*sessions.Metadata, error) {
|
||||
@@ -276,6 +280,9 @@ func (m model) resolveResumeSession(args string) (*sessions.Metadata, error) {
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("zero session not found: %s", args)
|
||||
}
|
||||
if !sessions.IsResumableKind(session.SessionKind) {
|
||||
return nil, fmt.Errorf("zero session is not resumable: %s", args)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
@@ -61,6 +63,7 @@ func (m model) handleEffortCommand(args string) (model, string) {
|
||||
}
|
||||
if args == "auto" {
|
||||
m.reasoningEffort = ""
|
||||
m = m.markProfileEffortTouched()
|
||||
return m, m.effortStatusCard("auto", "Reasoning effort selection will follow the active model/provider defaults.")
|
||||
}
|
||||
|
||||
@@ -78,6 +81,7 @@ func (m model) handleEffortCommand(args string) (model, string) {
|
||||
}
|
||||
|
||||
m.reasoningEffort = requested
|
||||
m = m.markProfileEffortTouched()
|
||||
return m, m.effortStatusCard(string(requested), "Reasoning effort preference is stored for this TUI session.")
|
||||
}
|
||||
|
||||
@@ -131,6 +135,104 @@ func (m model) effortText() string {
|
||||
})
|
||||
}
|
||||
|
||||
// markProfileEffortTouched records an explicit effort choice made while a
|
||||
// profile is active: the touched bit protects the choice from a later profile
|
||||
// revert, and disarming the policy's RestoreDefaultEffort stops a mid-run
|
||||
// escalation from clearing an effort the user pinned by hand.
|
||||
func (m model) markProfileEffortTouched() model {
|
||||
if m.execProfileName == "" {
|
||||
return m
|
||||
}
|
||||
m.execProfileEffortTouched = true
|
||||
return m.setProfileEffortRestore(false)
|
||||
}
|
||||
|
||||
// setProfileEffortRestore rewrites the armed policy's RestoreDefaultEffort on
|
||||
// a clone (an in-flight run may hold the current pointer), like the /turns
|
||||
// turn-target disarm. No-op when no escalation policy is armed or the flag
|
||||
// already matches.
|
||||
func (m model) setProfileEffortRestore(restore bool) model {
|
||||
if m.agentOptions.Profile == nil || m.agentOptions.Profile.Escalate == nil ||
|
||||
m.agentOptions.Profile.Escalate.RestoreDefaultEffort == restore {
|
||||
return m
|
||||
}
|
||||
policy := *m.agentOptions.Profile
|
||||
escalate := *policy.Escalate
|
||||
escalate.RestoreDefaultEffort = restore
|
||||
policy.Escalate = &escalate
|
||||
m.agentOptions.Profile = &policy
|
||||
return m
|
||||
}
|
||||
|
||||
// reconcileEffortForModelSwitch applies the effort rules for a model switch.
|
||||
// efforts is the destination's supported ring and ringKnown whether that ring
|
||||
// is authoritative (a catalog entry, whose ring may be legitimately empty) or
|
||||
// missing knowledge (a live-discovered/custom model the catalog cannot vouch
|
||||
// for either way).
|
||||
//
|
||||
// First the pre-existing generic rule: a KNOWN-unsupported preference is
|
||||
// dropped — the TUI run path forwards the effort to the provider unfiltered,
|
||||
// so it must not survive the switch, for a profile-touched effort exactly as
|
||||
// for a plain one. An UNKNOWN ring never drops an explicit preference: the
|
||||
// model may well support it, matching the cross-provider picker's behavior
|
||||
// for custom models. When a drop voids a choice living under an active
|
||||
// profile (the profile's own fill or a user override of it), the profile's
|
||||
// effort bookkeeping resets so a choice that no longer exists stops binding
|
||||
// and no stale applied/restore state remains.
|
||||
//
|
||||
// Then the profile rule: an active, untouched profile re-derives its fill for
|
||||
// the destination (conservative in both directions — the fill only ever
|
||||
// applies where support is known). The second return reports whether an
|
||||
// unsupported preference was dropped and nothing refilled (the caller's
|
||||
// "(unsupported preference reset)" display case).
|
||||
func (m model) reconcileEffortForModelSwitch(efforts []modelregistry.ReasoningEffort, ringKnown bool) (model, bool) {
|
||||
dropped := false
|
||||
if ringKnown && m.reasoningEffort != "" && !reasoningEffortAllowed(efforts, m.reasoningEffort) {
|
||||
m.reasoningEffort = ""
|
||||
dropped = true
|
||||
if m.execProfileName != "" {
|
||||
m.execProfileEffortTouched = false
|
||||
m.execProfileAppliedEffort = ""
|
||||
m = m.setProfileEffortRestore(false)
|
||||
}
|
||||
}
|
||||
m = m.reconcileProfileAfterModelSwitch(efforts)
|
||||
return m, dropped && m.reasoningEffort == ""
|
||||
}
|
||||
|
||||
// reconcileProfileAfterModelSwitch re-derives an active profile's effort fill
|
||||
// for the model the session just switched to, whose supported ring is passed
|
||||
// in. The fill is per-model, not a session preference: fast selected on a
|
||||
// model without "low" fills nothing, and switching to a model that supports
|
||||
// it should behave exactly like selecting the profile there (and the reverse
|
||||
// switch must not keep sending a level the destination does not support). An
|
||||
// explicitly touched effort is the user's choice and is never reconciled; the
|
||||
// escalation's RestoreDefaultEffort tracks whether the profile currently
|
||||
// governs the effort.
|
||||
func (m model) reconcileProfileAfterModelSwitch(efforts []modelregistry.ReasoningEffort) model {
|
||||
if m.execProfileName == "" || m.execProfileEffortTouched {
|
||||
return m
|
||||
}
|
||||
profile, ok := execprofile.Lookup(m.execProfileName)
|
||||
if !ok || profile.ReasoningEffort == "" {
|
||||
return m
|
||||
}
|
||||
want := modelregistry.ReasoningEffort(profile.ReasoningEffort)
|
||||
supported := reasoningEffortAllowed(efforts, want)
|
||||
filled := m.execProfileAppliedEffort != ""
|
||||
switch {
|
||||
case supported && !filled && m.reasoningEffort == "":
|
||||
m.reasoningEffort = want
|
||||
m.execProfileAppliedEffort = want
|
||||
m = m.setProfileEffortRestore(true)
|
||||
case !supported && filled && m.reasoningEffort == m.execProfileAppliedEffort:
|
||||
m.reasoningEffort = ""
|
||||
m.execProfileAppliedEffort = ""
|
||||
m = m.setProfileEffortRestore(false)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) availableReasoningEfforts() []modelregistry.ReasoningEffort {
|
||||
if strings.TrimSpace(m.modelName) == "" {
|
||||
return nil
|
||||
@@ -161,6 +263,10 @@ func (m model) cycleReasoningEffort() (model, tea.Cmd) {
|
||||
if len(efforts) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
// Every branch below changes the effort; a cycle while a profile is active
|
||||
// is an explicit user choice that must survive both a later profile revert
|
||||
// and any mid-run escalation.
|
||||
m = m.markProfileEffortTouched()
|
||||
if m.reasoningEffort == "" {
|
||||
m.reasoningEffort = efforts[0]
|
||||
return m, nil
|
||||
@@ -254,6 +360,12 @@ func (m model) handleSelfCorrectCommand(args string) (model, string) {
|
||||
default:
|
||||
return m, "Self-correct\nUsage: /selfcorrect [status|on|off|tests|full|lsp]"
|
||||
}
|
||||
// An explicit choice while a profile is active must survive a later
|
||||
// profile revert, even when it lands on the value the profile applied
|
||||
// (e.g. /selfcorrect off then on under thorough).
|
||||
if m.execProfileName != "" {
|
||||
m.execProfileSelfCorrectTouched = true
|
||||
}
|
||||
return m, m.selfCorrectText()
|
||||
}
|
||||
|
||||
@@ -278,9 +390,146 @@ func (m model) handleTurnsCommand(args string) (model, string) {
|
||||
// Propagate the budget to spawned sub-agents / swarm members (which inherit the
|
||||
// environment) so a delegated task gets the same budget, not config.json's default.
|
||||
config.SetMaxTurnsEnv(n)
|
||||
// An explicit /turns while a profile is active is a pinned budget: mirror
|
||||
// headless exec's rule (an explicit --max-turns leaves nothing displaced)
|
||||
// by disarming the escalation's turn target, so a mid-run escalation can
|
||||
// never raise the budget the user just pinned. The other escalation
|
||||
// triggers stay armed. Mark the knob touched so a later profile revert
|
||||
// leaves this choice alone even if n coincides with the profile's value.
|
||||
if m.execProfileName != "" {
|
||||
m.execProfileTurnsTouched = true
|
||||
if m.agentOptions.Profile != nil && m.agentOptions.Profile.Escalate != nil {
|
||||
policy := *m.agentOptions.Profile
|
||||
escalate := *policy.Escalate
|
||||
escalate.MaxTurns = 0
|
||||
policy.Escalate = &escalate
|
||||
m.agentOptions.Profile = &policy
|
||||
}
|
||||
}
|
||||
return m, m.turnsText()
|
||||
}
|
||||
|
||||
// handleProfileCommand shows or switches the session's execution profile: a
|
||||
// loop-posture bundle (turn budget, reasoning effort, self-correction,
|
||||
// escalation triggers) applied to the NEXT run, composing with the selected
|
||||
// model rather than replacing it. Switching first restores whatever the
|
||||
// previous profile displaced (profiles never stack), and balanced IS that
|
||||
// restore: the empty profile, so selecting it just clears the posture.
|
||||
func (m model) handleProfileCommand(args string) (model, string) {
|
||||
args = strings.TrimSpace(args)
|
||||
if args == "" || strings.EqualFold(args, "status") {
|
||||
return m, m.profileText()
|
||||
}
|
||||
profile, ok := execprofile.Lookup(args)
|
||||
if !ok {
|
||||
return m, "Profile\nUsage: /profile [status|" + strings.Join(execprofile.Names(), "|") + "] — switch the next run's loop posture."
|
||||
}
|
||||
m = m.revertExecProfile()
|
||||
if profile.Name == execprofile.Balanced.Name {
|
||||
return m, m.profileText()
|
||||
}
|
||||
// Turn budget: displace the session's current budget. The displaced value
|
||||
// becomes the escalation restore target, exactly like headless exec, and
|
||||
// the new budget propagates to spawned sub-agents the same way /turns does.
|
||||
displacedMaxTurns := 0
|
||||
if profile.MaxTurns > 0 {
|
||||
displacedMaxTurns = m.agentOptions.MaxTurns
|
||||
m.agentOptions.MaxTurns = profile.MaxTurns
|
||||
m.execProfileAppliedMaxTurns = profile.MaxTurns
|
||||
m.execProfileDisplacedMaxTurns = displacedMaxTurns
|
||||
config.SetMaxTurnsEnv(profile.MaxTurns)
|
||||
}
|
||||
// Reasoning effort: fill only when the session is on auto AND the active
|
||||
// model supports the profile's level, mirroring exec's supported-effort
|
||||
// gating. An explicit user choice always wins over the profile.
|
||||
if want := modelregistry.ReasoningEffort(profile.ReasoningEffort); want != "" && m.reasoningEffort == "" {
|
||||
if reasoningEffortAllowed(m.availableReasoningEfforts(), want) {
|
||||
m.reasoningEffort = want
|
||||
m.execProfileAppliedEffort = want
|
||||
}
|
||||
}
|
||||
// Self-correction is presence-only: a profile can arm it but never disarm
|
||||
// an explicit /selfcorrect choice.
|
||||
if profile.SelfCorrect && !m.selfCorrectTests {
|
||||
m.selfCorrectTests = true
|
||||
m.execProfileArmedSelfCorrect = true
|
||||
}
|
||||
m.agentOptions.Profile = profile.Policy(displacedMaxTurns, m.execProfileAppliedEffort != "")
|
||||
m.execProfileName = profile.Name
|
||||
return m, m.profileText()
|
||||
}
|
||||
|
||||
// revertExecProfile restores what the active profile displaced. Each knob is
|
||||
// restored only when the user has not explicitly touched it since the profile
|
||||
// was applied (the touched bits, set by /turns, /effort, Ctrl+T, and
|
||||
// /selfcorrect) AND it still holds the value the profile applied — so manual
|
||||
// overrides survive the revert even when they coincide with the profile's own
|
||||
// value (e.g. /turns 30 under fast, or /selfcorrect off-then-on under
|
||||
// thorough).
|
||||
func (m model) revertExecProfile() model {
|
||||
if m.execProfileName == "" {
|
||||
return m
|
||||
}
|
||||
if !m.execProfileTurnsTouched && m.execProfileAppliedMaxTurns > 0 && m.agentOptions.MaxTurns == m.execProfileAppliedMaxTurns {
|
||||
m.agentOptions.MaxTurns = m.execProfileDisplacedMaxTurns
|
||||
if m.execProfileDisplacedMaxTurns > 0 {
|
||||
config.SetMaxTurnsEnv(m.execProfileDisplacedMaxTurns)
|
||||
} else {
|
||||
// The profile's apply exported its budget to ZERO_MAX_TURNS (for
|
||||
// sub-agents), and SetMaxTurnsEnv ignores zero — so restoring a
|
||||
// zero displaced budget must clear the env explicitly or spawned
|
||||
// children would keep the removed profile's budget.
|
||||
_ = os.Unsetenv(config.MaxTurnsEnv)
|
||||
}
|
||||
}
|
||||
if !m.execProfileEffortTouched && m.execProfileAppliedEffort != "" && m.reasoningEffort == m.execProfileAppliedEffort {
|
||||
m.reasoningEffort = ""
|
||||
}
|
||||
if !m.execProfileSelfCorrectTouched && m.execProfileArmedSelfCorrect && m.selfCorrectTests {
|
||||
m.selfCorrectTests = false
|
||||
}
|
||||
m.agentOptions.Profile = nil
|
||||
m.execProfileName = ""
|
||||
m.execProfileDisplacedMaxTurns = 0
|
||||
m.execProfileAppliedMaxTurns = 0
|
||||
m.execProfileAppliedEffort = ""
|
||||
m.execProfileArmedSelfCorrect = false
|
||||
m.execProfileTurnsTouched = false
|
||||
m.execProfileEffortTouched = false
|
||||
m.execProfileSelfCorrectTouched = false
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) profileText() string {
|
||||
name := m.execProfileName
|
||||
if name == "" {
|
||||
name = execprofile.Balanced.Name + " (default)"
|
||||
}
|
||||
lines := []string{
|
||||
"execution profile: " + name,
|
||||
fmt.Sprintf("max tool-turns per run: %d", m.agentOptions.MaxTurns),
|
||||
"reasoning effort: " + m.effortDisplay(),
|
||||
}
|
||||
if m.agentOptions.Profile != nil && m.agentOptions.Profile.Escalate != nil {
|
||||
turnTarget := "keeps the pinned turn budget"
|
||||
if target := m.agentOptions.Profile.Escalate.MaxTurns; target > 0 {
|
||||
turnTarget = fmt.Sprintf("restores the turn budget to %d", target)
|
||||
}
|
||||
lines = append(lines,
|
||||
"escalation: armed — one-shot on a tool-failure streak, a failing self-correct cycle, or a critical-risk mutation; "+turnTarget,
|
||||
"note: the uncertain-completion signal is headless-only (the completion gate never runs interactively), so it cannot fire in the TUI")
|
||||
}
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Profile",
|
||||
Status: commandStatusOK,
|
||||
Sections: []commandSection{{
|
||||
Title: "State",
|
||||
Lines: lines,
|
||||
}},
|
||||
Hints: []string{"/profile " + strings.Join(execprofile.Names(), "|") + " switches the next run's loop posture (turn budget, effort, self-correction, escalation); pick the model separately with /model"},
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) turnsText() string {
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Turns",
|
||||
|
||||
+12
-8
@@ -195,17 +195,21 @@ func (m model) statusLine(width int) string {
|
||||
// in its mode colour. This was previously only on the easy-to-miss composer
|
||||
// rule; the persistent footer is where users look for "will it run commands?".
|
||||
modeText, modeStyle := m.modeLabel()
|
||||
left := prefix + zeroTheme.accent.Render("●") + " " + modeStyle.Render(modeText)
|
||||
btwChip := ""
|
||||
if m.btw.active {
|
||||
btwChip = zeroTheme.amber.Render("BTW") + zeroTheme.muted.Render(" · ")
|
||||
}
|
||||
left := prefix + btwChip + zeroTheme.accent.Render("●") + " " + modeStyle.Render(modeText)
|
||||
|
||||
if tier == tierTiny {
|
||||
if m.exitConfirmActive {
|
||||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
|
||||
return fitStyledLine(prefix+btwChip+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
|
||||
}
|
||||
if m.cancelConfirmActive {
|
||||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
|
||||
return fitStyledLine(prefix+btwChip+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
|
||||
}
|
||||
if dictation := m.dictationStatusChip(); dictation != "" {
|
||||
return fitStyledLine(prefix+dictation, width)
|
||||
return fitStyledLine(prefix+btwChip+dictation, width)
|
||||
}
|
||||
return fitStyledLine(left, width)
|
||||
}
|
||||
@@ -215,16 +219,16 @@ func (m model) statusLine(width int) string {
|
||||
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort))
|
||||
}
|
||||
if m.exitConfirmActive {
|
||||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
|
||||
left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
|
||||
} else if m.cancelConfirmActive {
|
||||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
|
||||
left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
|
||||
} else if m.dictation.downloading && m.dictation.downloadStatus != "" {
|
||||
// A model download in progress takes over the left chip with a live percentage.
|
||||
left = prefix + zeroTheme.accent.Render("⬇ ") + zeroTheme.muted.Render(m.dictation.downloadStatus)
|
||||
left = prefix + btwChip + zeroTheme.accent.Render("⬇ ") + zeroTheme.muted.Render(m.dictation.downloadStatus)
|
||||
} else if dictation := m.dictationStatusChip(); dictation != "" && m.dictation.active() {
|
||||
// An active recording/transcription takes over the left chip — it is the
|
||||
// most time-sensitive thing on screen (the mic is live).
|
||||
left = prefix + dictation
|
||||
left = prefix + btwChip + dictation
|
||||
} else {
|
||||
if voice := m.voiceModeIndicator(); voice != "" {
|
||||
left += zeroTheme.muted.Render(" · ") + voice
|
||||
|
||||
Reference in new issue
Block a user