mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe98067153 | ||
|
|
52b3338a40 | ||
|
|
da9fb50f54 | ||
|
|
684901165d | ||
|
|
e1975c1b39 | ||
|
|
8c6d3022bd | ||
|
|
974fc036c2 | ||
|
|
4cbd144d11 | ||
|
|
c0b6b9e5c9 | ||
|
|
d74ceb1127 | ||
|
|
fbf85984f6 |
No files matched your search
@@ -384,6 +384,15 @@ go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||
go install golang.org/x/vuln/cmd/[email protected]
|
||||
```
|
||||
|
||||
The installed binaries land in `$GOBIN` when it is set, otherwise in
|
||||
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
|
||||
run them directly. If it isn't, add it:
|
||||
|
||||
```bash
|
||||
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
|
||||
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
|
||||
```
|
||||
|
||||
### Cross-Compile Examples
|
||||
|
||||
```bash
|
||||
|
||||
@@ -169,6 +169,37 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of #702's fix: the unknown-session error's normalized
|
||||
// signature must not vary with the session id, so a model probing ids 1, 2,
|
||||
// 3, … produces one repeated signature the failure guard can count. If the id
|
||||
// leaked into the first 80 normalized chars, each probe would reset the streak
|
||||
// and the halt would never fire.
|
||||
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
|
||||
a := errorSignature(tools.UnknownExecSessionError(1))
|
||||
b := errorSignature(tools.UnknownExecSessionError(999999))
|
||||
if a != b {
|
||||
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end: with an id-invariant signature, probing a different unknown id
|
||||
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
|
||||
// before the fix it never would.
|
||||
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
|
||||
var state guardState
|
||||
var stoppedAt int
|
||||
for i := 1; i <= toolFailureStopAt; i++ {
|
||||
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
|
||||
if out.Stop {
|
||||
stoppedAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if stoppedAt != toolFailureStopAt {
|
||||
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
|
||||
var state guardState
|
||||
toolOnly := zeroruntime.CollectedStream{
|
||||
|
||||
@@ -100,6 +100,9 @@ work.
|
||||
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
||||
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
|
||||
@@ -53,6 +53,22 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
// SetActiveProvider only ever matches profiles persisted in config.json
|
||||
// (see config.ProviderPersisted), but a provider can be visible in
|
||||
// `zero providers list`/the TUI picker purely because Resolve()
|
||||
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
|
||||
// without ever writing a row to disk. Without this check, switching to
|
||||
// that provider by name always fails with a confusing "not found" even
|
||||
// though it is genuinely usable this session (issue #707).
|
||||
persisted, err := config.ProviderPersisted(configPath, options.name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderUse(stdout, stderr, deps, options, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.SetActiveProvider(configPath, options.name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -352,6 +368,22 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
name := options.names[0]
|
||||
// RemoveProvider only ever matches profiles persisted in config.json (see
|
||||
// config.ProviderPersisted), but a provider can be visible in
|
||||
// `zero providers list`/the TUI picker purely because Resolve()
|
||||
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
|
||||
// without ever writing a row to disk. Without this check, deleting that
|
||||
// provider by name always fails with a confusing "not found" even though
|
||||
// it is genuinely visible/usable this session (issue #707).
|
||||
persisted, err := config.ProviderPersisted(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderRemove(stdout, stderr, deps, name, options.json, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.RemoveProvider(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -428,6 +460,16 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
oldName := options.names[0]
|
||||
persisted, err := config.ProviderPersisted(configPath, oldName)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderRename(stdout, stderr, deps, oldName, options.json, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -447,3 +489,120 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// providerResolvedByName reports whether name matches a provider in a
|
||||
// resolved provider list — used to tell a genuinely unknown name (a typo)
|
||||
// apart from a real, env-derived provider that just has no config.json row.
|
||||
func providerResolvedByName(providers []config.ProviderProfile, name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, provider := range providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reportUnpersistedProviderUse handles `zero providers use <name>` for a
|
||||
// provider that is not persisted in config.json. If it's not resolvable at
|
||||
// all (an unknown/misspelled name), it returns handled=false so the caller
|
||||
// falls through to SetActiveProvider's real "not found" error. If it IS
|
||||
// resolvable — an env-derived profile (e.g. ambient OPENAI_API_KEY) —
|
||||
// SetActiveProvider would only ever fail "not found" against it, so this
|
||||
// reports the situation plainly instead of that confusing error (issue
|
||||
// #707).
|
||||
func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, options providerUseOptions, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
// resolveCommandCenterConfig already wrote its own error to stderr;
|
||||
// stop here instead of letting the caller try SetActiveProvider too.
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, options.name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to switch to.\nIt is available whenever its environment variable is set, but is only active when selected (for example via ZERO_PROVIDER); unset its environment variable to stop Zero from detecting it automatically.",
|
||||
options.name,
|
||||
)
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"activeProvider": resolved.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
|
||||
// reportUnpersistedProviderRemove handles `zero providers remove <name>` for
|
||||
// a provider that is not persisted in config.json, mirroring the TUI
|
||||
// provider manager's delete handling (internal/tui/provider_manager.go). If
|
||||
// name isn't resolvable at all, it returns handled=false so the caller falls
|
||||
// through to RemoveProvider's real "not found" error.
|
||||
func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable) — nothing to remove there.\nUnset its environment variable to stop Zero from detecting it automatically.",
|
||||
name,
|
||||
)
|
||||
if jsonOutput {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"removed": "",
|
||||
"keyRemoved": false,
|
||||
"activeProvider": resolved.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
|
||||
func reportUnpersistedProviderRename(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to rename.",
|
||||
name,
|
||||
)
|
||||
if jsonOutput {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"renamed": nil,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
@@ -75,6 +75,111 @@ func TestRunProvidersUseJSONIncludesActiveProviderAndConfigPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte("{"), 0o600); err != nil {
|
||||
t.Fatalf("write malformed config: %v", err)
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("exit code = %d, want %d", exitCode, exitCrash)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid config JSON") {
|
||||
t.Fatalf("stderr = %q, want malformed-config error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
ConfigPath string `json:"configPath"`
|
||||
Persisted bool `json:"persisted"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.ConfigPath != configPath || payload.Persisted {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Removed string `json:"removed"`
|
||||
KeyRemoved bool `json:"keyRemoved"`
|
||||
Persisted bool `json:"persisted"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Removed != "" || payload.KeyRemoved || payload.Persisted || payload.ConfigPath != configPath {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no saved profile to rename") {
|
||||
t.Fatalf("stdout = %q, want unpersisted explanation", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Renamed *struct{} `json:"renamed"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
Persisted bool `json:"persisted"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Renamed != nil || payload.ConfigPath != configPath || payload.Persisted {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseRejectsUsageErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -177,6 +177,33 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// ProviderPersisted reports whether a provider profile named name actually has
|
||||
// a row in the config file at path. A provider can appear in the resolved/
|
||||
// in-memory provider list without ever being written to config.json — e.g.
|
||||
// applyProviderEnv synthesizes an "openai" profile purely from an ambient
|
||||
// OPENAI_API_KEY environment variable on every Resolve() call, without ever
|
||||
// persisting it. RemoveProvider/SetActiveProvider/SetProviderModel only ever
|
||||
// look at what's on disk, so a caller offering to mutate a provider by name
|
||||
// should check this first: "not on disk" needs different handling (nothing to
|
||||
// persist/remove there) than a name that doesn't exist anywhere at all.
|
||||
func ProviderPersisted(path string, name string) (bool, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
name = strings.TrimSpace(name)
|
||||
if path == "" || name == "" {
|
||||
return false, nil
|
||||
}
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, provider := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// RemoveProvider deletes the named provider profile from the config at path.
|
||||
// When the removed profile was active, activeProvider hands off to the first
|
||||
// remaining provider (or clears when none remain) so the config never points at
|
||||
|
||||
@@ -49,9 +49,9 @@ func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now
|
||||
if err := validateTokenEndpoint(endpoint); err != nil {
|
||||
return DeviceAuth{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
// Refuse redirects so a device-authorization redirect can't replay the
|
||||
// client_id/client_secret POST body to an unvalidated origin.
|
||||
client = withoutRedirects(client)
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
@@ -157,9 +157,9 @@ func pollDeviceOnce(ctx context.Context, client *http.Client, cfg Config, device
|
||||
if err := validateTokenEndpoint(cfg.TokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
// Refuse redirects so a poll redirect can't replay the device_code/secret
|
||||
// POST body to an unvalidated origin.
|
||||
client = withoutRedirects(client)
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", deviceGrantType)
|
||||
form.Set("device_code", deviceCode)
|
||||
|
||||
@@ -2,6 +2,7 @@ package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -28,6 +29,30 @@ func TestRequestDeviceCodeSendsClientSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCodeRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
|
||||
_, err := RequestDeviceCode(context.Background(), http.DefaultClient,
|
||||
Config{ClientID: "c", ClientSecret: "shh", DeviceAuthorizationEndpoint: endpoint}, nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("RequestDeviceCode err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — client_secret replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceOnceRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusPermanentRedirect) // 308
|
||||
_, err := pollDeviceOnce(context.Background(), http.DefaultClient,
|
||||
Config{ClientID: "c", ClientSecret: "shh", TokenEndpoint: endpoint}, "device-code", nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("pollDeviceOnce err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — device_code/secret replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WXYZ-1234","verification_uri":"https://x/dev","verification_uri_complete":"https://x/dev?c=WXYZ","expires_in":600,"interval":3}`))
|
||||
|
||||
+20
-3
@@ -116,6 +116,23 @@ func validateTokenEndpoint(endpoint string) error {
|
||||
return ValidateEndpointURL(endpoint)
|
||||
}
|
||||
|
||||
// withoutRedirects returns a shallow copy of client whose redirect policy
|
||||
// refuses to follow ANY redirect. A credential-bearing OAuth POST (token
|
||||
// exchange/refresh, device authorization, device-token poll) must never let a
|
||||
// 307/308 replay its form body — codes, PKCE verifiers, refresh tokens, client
|
||||
// secrets — to a target that was never endpoint-validated. The caller's client
|
||||
// is left unmodified.
|
||||
func withoutRedirects(client *http.Client) *http.Client {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
copied := *client
|
||||
copied.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return ErrUnsafeRedirect
|
||||
}
|
||||
return &copied
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
@@ -176,9 +193,9 @@ func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, f
|
||||
if err := validateTokenEndpoint(tokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
// Refuse redirects so a 307/308 from the token endpoint can't replay this
|
||||
// credential-bearing POST body to another (unvalidated) origin.
|
||||
client = withoutRedirects(client)
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -126,6 +128,64 @@ func TestBuildAuthorizationURLRejectsInsecureEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// redirectTrap starts an "attacker" server that counts any hit, and a credential
|
||||
// endpoint that redirects to it with the given status. It returns the endpoint
|
||||
// URL and a func reporting how many times the attacker was contacted — a
|
||||
// non-zero count means the credential-bearing POST body was replayed (#729).
|
||||
func redirectTrap(t *testing.T, status int) (endpoint string, attackerHits func() int32) {
|
||||
t.Helper()
|
||||
var hits int32
|
||||
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
_, _ = io.WriteString(w, `{"access_token":"leaked","refresh_token":"leaked"}`)
|
||||
}))
|
||||
t.Cleanup(attacker.Close)
|
||||
endp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, attacker.URL, status)
|
||||
}))
|
||||
t.Cleanup(endp.Close)
|
||||
return endp.URL, func() int32 { return atomic.LoadInt32(&hits) }
|
||||
}
|
||||
|
||||
func TestPostTokenRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", "the-code")
|
||||
form.Set("client_secret", "shh")
|
||||
_, err := PostToken(context.Background(), http.DefaultClient, endpoint, form, Token{}, nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("PostToken err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — credential body was replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusPermanentRedirect) // 308
|
||||
_, err := ExchangeCode(context.Background(), http.DefaultClient,
|
||||
Config{ClientID: "c", TokenEndpoint: endpoint}, "the-code", "verifier", "http://127.0.0.1/cb", nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("ExchangeCode err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — code/verifier replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
|
||||
_, err := Refresh(context.Background(), http.DefaultClient,
|
||||
Config{ClientID: "c", TokenEndpoint: endpoint}, Token{RefreshToken: "rt"}, nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("Refresh err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — refresh_token replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenEndpoint(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://auth.example.com/token": true,
|
||||
|
||||
@@ -90,6 +90,11 @@ var (
|
||||
// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
|
||||
// non-https, non-loopback endpoint.
|
||||
ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
|
||||
// ErrUnsafeRedirect is returned when a credential-bearing OAuth POST (token
|
||||
// exchange/refresh, device authorization, or device-token poll) is redirected.
|
||||
// Following it would replay the form body — codes, PKCE verifiers, refresh
|
||||
// tokens, client secrets — to a target that never passed endpoint validation.
|
||||
ErrUnsafeRedirect = errors.New("oauth: refusing to follow a redirect from a credential endpoint")
|
||||
// ErrNoRefreshToken is returned when a refresh is attempted without one.
|
||||
ErrNoRefreshToken = errors.New("oauth: no refresh token available")
|
||||
// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
|
||||
|
||||
@@ -578,28 +578,54 @@ func WriteTurnBenchJSON(w io.Writer, result TurnBenchResult) error {
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
// execExitIncomplete mirrors internal/cli.exitIncomplete (the `zero exec` exit
|
||||
// code, 4) for a headless run the completion gate marked INCOMPLETE: the run
|
||||
// stopped without a completion signal (e.g. it couldn't self-verify because its
|
||||
// sandboxed shell was unavailable under --auto member), which is distinct from a
|
||||
// crash (1), usage error (2), provider failure (3), or interruption (130). It is
|
||||
// the ONLY nonzero exit an oracle-bearing task may defer to the oracle for; every
|
||||
// other nonzero exit stays authoritative. Kept as a local literal so perfbench
|
||||
// doesn't import the cli package; must stay in sync with that constant.
|
||||
const execExitIncomplete = 4
|
||||
|
||||
// NewTurnExecRunner builds the production turn-benchmark runner: it invokes
|
||||
// headless `zero exec` with stream-json output AND `--trace <tmpfile>`, then
|
||||
// parses the emitted NDJSON trace into a *trace.TurnTrace. binary is the path to
|
||||
// the `zero` binary; extraArgs are appended to every invocation. Pass/fail is
|
||||
// decided from the stream-json run_end exit code (and the task's
|
||||
// VerificationCommand when present), exactly like NewExecRunner.
|
||||
// decided by the task's oracle when it has one. The oracle that gates the verdict
|
||||
// is the VerificationCommand (which compiles and runs the stamped OracleTest via
|
||||
// `go test`, plus any greps against the fixture); it is ground truth, so an
|
||||
// oracle-bearing task that applied the right edit but exited INCOMPLETE (exit 4,
|
||||
// its sandboxed self-verification couldn't run) still passes. Any OTHER nonzero
|
||||
// exit (crash/usage/provider/interrupt) stays authoritative and fails even an
|
||||
// oracle-bearing task. A latency-only task has no VerificationCommand, so any
|
||||
// nonzero exit is its only signal and fails it.
|
||||
func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
|
||||
return func(ctx context.Context, task BenchTask, rc RunContext) TurnTaskOutcome {
|
||||
// buildTurnExecArgs grants the write + sandboxed-shell tool set to EVERY
|
||||
// invocation, so a task MUST run inside an isolated fixture copy — never the
|
||||
// caller's cwd. The grant is unconditional, so the isolation can't be
|
||||
// optional: a fixtureless task would turn an agent with write/shell tools
|
||||
// loose in the caller's working directory (their real repo). Enforce the
|
||||
// invariant instead of trusting it — reject a fixtureless task up front,
|
||||
// before anything is launched, so the grant and the guard can't drift apart.
|
||||
// Every shipped task declares a fixture, so this only fires on a malformed or
|
||||
// newly added task, and it fails loudly rather than silently.
|
||||
if strings.TrimSpace(task.WorkspaceFixture) == "" {
|
||||
return TurnTaskOutcome{Err: errors.New("benchmark task has no workspaceFixture: the run grants write/shell tools and must execute in an isolated fixture copy, not the caller's cwd")}
|
||||
}
|
||||
// Isolate the workspace: copy the fixture into a fresh temp dir so a
|
||||
// mutating task (edit/fix/refactor) can't dirty the shared, checked-in
|
||||
// fixture or bleed into a later iteration of the same task. When no
|
||||
// fixture is configured the agent runs in the caller's cwd as before.
|
||||
if fixture := strings.TrimSpace(task.WorkspaceFixture); fixture != "" {
|
||||
copyDir, parent, cerr := copyFixture(fixture)
|
||||
if cerr != nil {
|
||||
return TurnTaskOutcome{Err: fmt.Errorf("isolate fixture: %w", cerr)}
|
||||
}
|
||||
// Clean the whole unique parent (which owns copyDir) so the
|
||||
// per-invocation scratch dir never leaks.
|
||||
defer os.RemoveAll(parent)
|
||||
task.WorkspaceFixture = copyDir
|
||||
// fixture or bleed into a later iteration of the same task. The guard above
|
||||
// guarantees WorkspaceFixture is set, so this always runs.
|
||||
copyDir, parent, cerr := copyFixture(strings.TrimSpace(task.WorkspaceFixture))
|
||||
if cerr != nil {
|
||||
return TurnTaskOutcome{Err: fmt.Errorf("isolate fixture: %w", cerr)}
|
||||
}
|
||||
// Clean the whole unique parent (which owns copyDir) so the per-invocation
|
||||
// scratch dir never leaks.
|
||||
defer os.RemoveAll(parent)
|
||||
task.WorkspaceFixture = copyDir
|
||||
|
||||
traceFile, err := os.CreateTemp("", "zero-turn-trace-*.ndjson")
|
||||
if err != nil {
|
||||
@@ -656,10 +682,23 @@ func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// A nonzero agent exit already decided failure; don't run verification or
|
||||
// mark the task passed.
|
||||
// Decide what a nonzero exit means. We defer to the oracle for exactly ONE
|
||||
// case: an oracle-bearing task that exited INCOMPLETE (exit 4). That is the
|
||||
// completion gate downgrading a run whose edits may be correct but that
|
||||
// couldn't emit a completion signal (e.g. its sandboxed self-verify couldn't
|
||||
// run under --auto member) — the oracle below (the compiled test / grep run
|
||||
// against the actual fixture files) is ground truth, so if the edit really
|
||||
// landed it passes, and if the run abandoned the task the oracle fails it.
|
||||
// Every OTHER nonzero exit stays authoritative: a crash (1), usage error
|
||||
// (2), provider failure (3), or interruption (130) is a genuine failure, so
|
||||
// a partial edit that happens to satisfy the oracle can't launder it into a
|
||||
// pass. A latency-only task has no oracle to defer to, so any nonzero exit
|
||||
// fails it.
|
||||
if outcome.VerifyErr != "" {
|
||||
return outcome
|
||||
if len(task.VerificationCommand) == 0 || exitCode != execExitIncomplete {
|
||||
return outcome
|
||||
}
|
||||
outcome.VerifyErr = ""
|
||||
}
|
||||
// Stamp the compiler-backed oracle and capture the agent's final answer
|
||||
// BEFORE running the verification command. Both write into the fixture
|
||||
@@ -790,7 +829,23 @@ func stampOracleAndAnswer(task BenchTask, outBuf []byte) error {
|
||||
}
|
||||
|
||||
func buildTurnExecArgs(task BenchTask, rc RunContext, tracePath string, extraArgs []string) []string {
|
||||
args := []string{"exec", "--output-format", "stream-json", "--trace", tracePath}
|
||||
// --auto member is REQUIRED, not optional: without a permission grant `zero
|
||||
// exec` runs in its default read-only posture, which exposes no write or shell
|
||||
// tools (no edit_file/apply_patch/write_file/exec_command/bash). The mutating
|
||||
// task classes (edit/fix/refactor) then cannot apply any change, so every run
|
||||
// grinds to the turn ceiling or reports a no-tool blocker, and the only tasks
|
||||
// that "pass" are ones whose oracle grep matches the stamped answer text rather
|
||||
// than a real edit. member-auto grants the write + sandboxed-shell tools the
|
||||
// benchmark needs while keeping the workspace/network/destructive safeguards, so
|
||||
// it is preferred over the broader --skip-permissions-unsafe (which also drops
|
||||
// those guards and auto-retries blocked commands unsandboxed). Note the shell it
|
||||
// grants is sandboxed: on a host without sandbox setup the agent's own
|
||||
// self-verification (go test/build) can't run and the turn exits INCOMPLETE even
|
||||
// after a correct edit — the runner treats the stamped oracle, not that exit
|
||||
// code, as ground truth for oracle-bearing tasks (see NewTurnExecRunner), so a
|
||||
// correct edit still passes. Each task also runs in an isolated, throwaway
|
||||
// fixture copy, so the granted tools have nothing outside the task to harm.
|
||||
args := []string{"exec", "--auto", "member", "--output-format", "stream-json", "--trace", tracePath}
|
||||
if model := strings.TrimSpace(rc.Model); model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -750,6 +751,92 @@ echo '{"type":"run_end","exitCode":0}'
|
||||
}
|
||||
}
|
||||
|
||||
// TestOracleAuthoritativeOnIncompleteExit is the companion to the --auto member
|
||||
// switch: under member-auto the agent's shell is sandboxed, so on a host without
|
||||
// sandbox setup its self-verification (go test/build) can't run and the turn
|
||||
// exits INCOMPLETE (exit 4) even after a correct edit. For an ORACLE-bearing task
|
||||
// the runner must treat the stamped oracle — not that INCOMPLETE exit — as ground
|
||||
// truth: here the stub applies the real edit-01 rename but reports exitCode 4, and
|
||||
// the task must still pass because the fixture is correct. This defers ONLY for
|
||||
// exit 4; TestNonIncompleteExitStaysAuthoritative pins the other side.
|
||||
func TestOracleAuthoritativeOnIncompleteExit(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-01")
|
||||
outcome := runTurnStub(t, task, `sed 's/const MaxRetries = 3/const RetryLimit = 3/' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":4}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("incomplete-exit with a correct edit should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("a correct edit that exited INCOMPLETE (exit 4) must still pass its oracle: %+v", outcome)
|
||||
}
|
||||
if strings.TrimSpace(outcome.VerifyErr) != "" {
|
||||
t.Fatalf("a passing oracle must clear the exit-code VerifyErr, got %q", outcome.VerifyErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonIncompleteExitStaysAuthoritative is the guard the oracle-authoritative
|
||||
// change MUST NOT weaken: only INCOMPLETE (exit 4) defers to the oracle. A crash
|
||||
// (1), provider failure (3), or interruption (130) is a genuine failure, so it
|
||||
// stays authoritative even for an oracle-bearing task — otherwise a partial edit
|
||||
// that happens to satisfy the oracle would launder a crashed or interrupted run
|
||||
// into a pass. Each case applies the CORRECT edit-01 rename (so the oracle WOULD
|
||||
// pass) but reports the failing exit code; the task must still fail, and the
|
||||
// failure must be attributed to the exit code, not the oracle.
|
||||
func TestNonIncompleteExitStaysAuthoritative(t *testing.T) {
|
||||
for _, code := range []int{1, 3, 130} {
|
||||
t.Run(fmt.Sprintf("exit%d", code), func(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-01")
|
||||
outcome := runTurnStub(t, task, fmt.Sprintf(`sed 's/const MaxRetries = 3/const RetryLimit = 3/' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":%d}'
|
||||
`, code))
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("a nonzero exit should be a task fail, not a harness error: %v", outcome.Err)
|
||||
}
|
||||
if outcome.Passed {
|
||||
t.Fatalf("a run that exited %d must not pass even when the edit satisfies the oracle: %+v", code, outcome)
|
||||
}
|
||||
if want := fmt.Sprintf("exit code %d", code); !strings.Contains(outcome.VerifyErr, want) {
|
||||
t.Fatalf("a non-INCOMPLETE nonzero exit must stay authoritative and surface its code, got VerifyErr=%q", outcome.VerifyErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIncompleteExitStillFailsWhenOracleFails pins the OTHER half of the exit-4
|
||||
// deferral: deferring to the oracle on INCOMPLETE must still MEAN the oracle is
|
||||
// consulted, not a blanket pass. Here the stub does no real work and exits
|
||||
// INCOMPLETE (4); the edit-01 rename never happened, so the oracle fails and the
|
||||
// task fails. Without this, a regression that turned exit 4 into an unconditional
|
||||
// pass would slip through, because its sibling TestOracleAuthoritativeOnIncomplete-
|
||||
// Exit applies a CORRECT edit and would pass either way.
|
||||
func TestIncompleteExitStillFailsWhenOracleFails(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-01")
|
||||
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":4}'
|
||||
`)
|
||||
assertVerifyFailed(t, "incomplete exit with no edit applied", outcome)
|
||||
}
|
||||
|
||||
// TestNonzeroExitStillFailsLatencyOnly is the guard on the other side of that
|
||||
// change: a latency-only task has no oracle to appeal to, so the exit code is
|
||||
// the ONLY correctness signal and a nonzero exit must still fail it. Without
|
||||
// this, dropping the exit-code gate for oracle tasks could be misread as
|
||||
// dropping it everywhere.
|
||||
func TestNonzeroExitStillFailsLatencyOnly(t *testing.T) {
|
||||
task := loadBaselineTask(t, "longproc-01")
|
||||
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":4}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("latency-only nonzero exit should be a verify fail, not a harness error: %v", outcome.Err)
|
||||
}
|
||||
if outcome.Passed {
|
||||
t.Fatalf("a latency-only task that exited nonzero must not pass: %+v", outcome)
|
||||
}
|
||||
if strings.TrimSpace(outcome.VerifyErr) == "" {
|
||||
t.Fatalf("a latency-only nonzero exit must surface a VerifyErr, got none: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Satisfiable tests: the oracle PASSES the right thing (real fix applied) ---
|
||||
|
||||
// TestStampedOraclePassesWhenRefactorHappened proves the refactor-01 oracle is
|
||||
@@ -1192,6 +1279,57 @@ func TestBuildTurnExecArgsIncludesExecProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every benchmark invocation MUST grant the write/shell tool set via --auto
|
||||
// member. Without a permission grant the agent runs read-only and cannot apply
|
||||
// any edit, so the mutating classes measure nothing but oracle/answer
|
||||
// contamination. member-auto is the RIGHT grant: it exposes the write +
|
||||
// sandboxed-shell tools the benchmark needs while keeping the workspace/network/
|
||||
// destructive safeguards, so the args must NOT reach for the broader
|
||||
// --skip-permissions-unsafe (which drops those guards). This is a correctness
|
||||
// contract, not a preference.
|
||||
func TestBuildTurnExecArgsGrantsWriteTools(t *testing.T) {
|
||||
args := buildTurnExecArgs(BenchTask{ID: "t", Prompt: "edit the file"}, RunContext{Model: "m"}, "trace.ndjson", nil)
|
||||
autoMember := false
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "--auto" && args[i+1] == "member" {
|
||||
autoMember = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !autoMember {
|
||||
t.Fatalf("benchmark exec args must include --auto member so the agent can apply edits, got %v", args)
|
||||
}
|
||||
for _, arg := range args {
|
||||
if arg == "--skip-permissions-unsafe" {
|
||||
t.Fatalf("benchmark exec args must NOT use --skip-permissions-unsafe (member-auto keeps the sandbox/network guards), got %v", args)
|
||||
}
|
||||
}
|
||||
if args[len(args)-1] != "edit the file" {
|
||||
t.Fatalf("prompt must stay the last argument, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnRunnerRejectsFixturelessTask is the other half of the write-tools
|
||||
// contract: because buildTurnExecArgs grants the write + sandboxed-shell tool
|
||||
// set to every invocation, a task with no fixture would run an agent with those
|
||||
// tools in the caller's cwd. The runner must reject such a task BEFORE launching
|
||||
// zero exec. Passing a binary path that does not exist proves the rejection is
|
||||
// pre-launch — the outcome carries the fixture error, not a spawn error — so this
|
||||
// runs on every OS (it never reaches the POSIX-only exec stub).
|
||||
func TestTurnRunnerRejectsFixturelessTask(t *testing.T) {
|
||||
task := BenchTask{ID: "no-fixture", Prompt: "do a thing"} // no WorkspaceFixture set
|
||||
outcome := NewTurnExecRunner(filepath.Join(t.TempDir(), "does-not-exist-zero"))(context.Background(), task, RunContext{Model: "m"})
|
||||
if outcome.Err == nil {
|
||||
t.Fatalf("a fixtureless task must be rejected before launch, got %+v", outcome)
|
||||
}
|
||||
if !strings.Contains(outcome.Err.Error(), "workspaceFixture") {
|
||||
t.Fatalf("rejection must name the missing workspaceFixture, got: %v", outcome.Err)
|
||||
}
|
||||
if outcome.Passed {
|
||||
t.Fatalf("a rejected task must not pass, got %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -93,6 +93,40 @@ func TestSandboxedBashAutoAllowDoesNotAffectNonShell(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Forged re-entrancy markers (ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND at
|
||||
// an unsandboxed process boundary) must NOT let a shell command combine an
|
||||
// auto-allow decision with an unwrapped pass-through plan (issue #727). The
|
||||
// runner skips wrapping when the markers are present, so the policy must stop
|
||||
// auto-allowing on the basis of an "active sandbox" — the command takes the
|
||||
// normal prompt instead. Without the markers the identical setup auto-allows
|
||||
// (TestSandboxedBashAutoAllowedWhenSandboxActive), so this is the before/after.
|
||||
func TestForgedReentrancyMarkersDoNotAutoAllowUnwrappedShell(t *testing.T) {
|
||||
t.Setenv(EnvSandboxed, "1")
|
||||
t.Setenv(EnvSandboxBackend, string(BackendLinuxBwrap))
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: nativeWrappingBackend,
|
||||
})
|
||||
|
||||
// Policy side: the shell command must NOT be auto-allowed.
|
||||
decision := engine.Evaluate(context.Background(), bashRequest())
|
||||
if decision.Action != ActionPrompt || decision.AutoAllowed {
|
||||
t.Fatalf("forged markers must not auto-allow the shell command, got %#v", decision)
|
||||
}
|
||||
|
||||
// Planning side: the same command is an unwrapped pass-through. Together with
|
||||
// the prompt above, this proves auto-allow and unwrapped can no longer combine.
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "echo hi"}, Dir: root})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if plan.Wrapped {
|
||||
t.Fatalf("re-entrancy plan must be an unwrapped pass-through, got wrapped=%v", plan.Wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellSandboxActive reports correctly across backends and policy modes.
|
||||
func TestShellSandboxActive(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -232,6 +232,15 @@ func (engine *Engine) shellSandboxActive(policy Policy) bool {
|
||||
if policy.Mode == ModeDisabled {
|
||||
return false
|
||||
}
|
||||
// Re-entrancy skips wrapping: when the process carries the sandbox markers,
|
||||
// the runner returns a pass-through (unwrapped) plan, so the native sandbox
|
||||
// is NOT the boundary for THIS command. Because the markers are ambient and
|
||||
// forgeable at an unsandboxed process boundary (issue #727), auto-allowing a
|
||||
// shell command on that basis would run it unwrapped with no prompt. Mirror
|
||||
// the runner: report inactive so the command takes the normal approval path.
|
||||
if IsAlreadySandboxed() {
|
||||
return false
|
||||
}
|
||||
backend := engine.backend
|
||||
if !(backend.Available && backend.Executable != "" && backend.CommandWrapping && backend.NativeIsolation) {
|
||||
return false
|
||||
|
||||
@@ -707,6 +707,18 @@ type writeStdinTool struct {
|
||||
manager *execSessionManager
|
||||
}
|
||||
|
||||
// UnknownExecSessionError is the result returned when write_stdin targets a
|
||||
// session_id with no live exec session. The stable recovery guidance leads and
|
||||
// the numeric id trails on purpose: the agent's repeated-failure guard keys on
|
||||
// a normalized, truncated prefix of the error string, so keeping the id out of
|
||||
// that prefix makes a model probing ids 1, 2, 3, … produce ONE signature that
|
||||
// finally trips the halt instead of a fresh signature per id — while also
|
||||
// telling the model how to actually recover. See TestUnknownExecSessionErrorSignatureIsIDInvariant
|
||||
// in internal/agent, which pins this invariant against the real guard.
|
||||
func UnknownExecSessionError(sessionID int) string {
|
||||
return fmt.Sprintf("Error: write_stdin needs a session_id returned by a still-running exec_command; do not guess or probe session ids. Start a process with exec_command, or use write_file/edit_file/apply_patch for file changes. (no live session %d)", sessionID)
|
||||
}
|
||||
|
||||
func (writeStdinTool) outputCategory(map[string]any) outputCategory {
|
||||
return outputCategoryProcess
|
||||
}
|
||||
@@ -722,7 +734,7 @@ func NewWriteStdinTool(manager *execSessionManager) Tool {
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"session_id": {Type: "integer", Description: "Identifier of the running unified exec session."},
|
||||
"session_id": {Type: "integer", Description: "Identifier of a running unified exec session, as returned by exec_command. Never guess or probe ids.", Minimum: intPtr(1)},
|
||||
"chars": {Type: "string", Description: "Bytes to write to stdin. Defaults to empty, which polls without writing.", Default: ""},
|
||||
"yield_time_ms": {Type: "integer", Description: "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.", Default: defaultPollYieldTimeMS, Minimum: intPtr(1), Maximum: intPtr(maxPollYieldTimeMS)},
|
||||
"max_output_tokens": {Type: "integer", Description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.", Default: defaultMaxOutputTokens, Minimum: intPtr(1), Maximum: intPtr(maxExecOutputTokenRequest)},
|
||||
@@ -763,12 +775,20 @@ func (tool writeStdinTool) Run(ctx context.Context, args map[string]any) Result
|
||||
}
|
||||
|
||||
func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]any, _ RunOptions) Result {
|
||||
if value, ok := args["session_id"]; !ok || value == nil {
|
||||
return errorResult("Error: Invalid arguments for write_stdin: session_id is required")
|
||||
}
|
||||
sessionID, err := intArg(args, "session_id", 0, 1, 0)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
|
||||
// A missing, non-integer, or < 1 session_id all mean the same thing: the model
|
||||
// has no live session to write to. Route them to the SAME recovery guidance the
|
||||
// no-live-session case uses (UnknownExecSessionError) instead of a terse
|
||||
// "session_id must be at least 1". The terse error gives no way forward and, by
|
||||
// naming the minimum, nudges the model to try 1, 2, 3... — the exact id-probing
|
||||
// #749 works to suppress. The recovery message instead tells it to start a
|
||||
// session or edit files directly, and because that message is id-invariant, the
|
||||
// missing/zero/non-integer entry points and id-probing collapse to ONE
|
||||
// repeated-failure signature, so any mix of them accumulates toward the halt
|
||||
// rather than resetting the streak on each class of mistake.
|
||||
value, present := args["session_id"]
|
||||
sessionID, sessionErr := intArg(args, "session_id", 0, 1, 0)
|
||||
if !present || value == nil || sessionErr != nil {
|
||||
return errorResult(UnknownExecSessionError(sessionID))
|
||||
}
|
||||
chars, err := stringArgWithEmpty(args, "chars", "", false, true)
|
||||
if err != nil {
|
||||
@@ -784,7 +804,7 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
|
||||
}
|
||||
session, ok := tool.manager.get(sessionID)
|
||||
if !ok {
|
||||
return errorResult(fmt.Sprintf("Error: Unknown exec session_id %d.", sessionID))
|
||||
return errorResult(UnknownExecSessionError(sessionID))
|
||||
}
|
||||
session.touch()
|
||||
interrupted := false
|
||||
|
||||
@@ -881,7 +881,7 @@ func TestRegistryHonorsWriteStdinArgumentPermission(t *testing.T) {
|
||||
registry.Register(NewWriteStdinTool(newExecSessionManager()))
|
||||
|
||||
poll := registry.Run(context.Background(), WriteStdinToolName, map[string]any{"session_id": 9999})
|
||||
if poll.Status != StatusError || !strings.Contains(poll.Output, "Unknown exec session_id") {
|
||||
if poll.Status != StatusError || !strings.Contains(poll.Output, "still-running exec_command") {
|
||||
t.Fatalf("empty poll should reach tool without permission prompt, got status=%s output=%q", poll.Status, poll.Output)
|
||||
}
|
||||
|
||||
@@ -901,24 +901,57 @@ func TestWriteStdinReportsUnknownSession(t *testing.T) {
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %s, want error", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Unknown exec session_id 1234") {
|
||||
t.Fatalf("unexpected output: %q", result.Output)
|
||||
// The message must lead with recovery guidance and still identify the id.
|
||||
if !strings.Contains(result.Output, "still-running exec_command") {
|
||||
t.Fatalf("unknown-session error must carry recovery guidance, got: %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "1234") {
|
||||
t.Fatalf("unknown-session error must name the offending id, got: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime rejects session_id < 1 (intArg min 1); the advertised schema must
|
||||
// say the same so a model sees the constraint before it probes id 0.
|
||||
func TestWriteStdinSchemaPinsSessionIDMinimum(t *testing.T) {
|
||||
schema := NewWriteStdinTool(nil).(writeStdinTool).parameters
|
||||
prop, ok := schema.Properties["session_id"]
|
||||
if !ok {
|
||||
t.Fatal("session_id property missing from write_stdin schema")
|
||||
}
|
||||
if prop.Minimum == nil || *prop.Minimum != 1 {
|
||||
t.Fatalf("session_id schema Minimum = %v, want 1 to match the runtime floor", prop.Minimum)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing, zero, negative, or non-integer session_id all mean the model has no
|
||||
// live session, so write_stdin returns the SAME recovery guidance the no-live-
|
||||
// session case uses (start a session with exec_command, or edit files directly),
|
||||
// not a terse "session_id must be at least 1" that gives no way forward and names
|
||||
// a minimum that nudges the model to probe ids 1, 2, 3... Sharing one id-invariant
|
||||
// message also keeps a single repeated-failure signature, so any mix of these
|
||||
// mistakes accumulates toward the halt instead of resetting the streak (#749).
|
||||
func TestWriteStdinRequiresPositiveSessionID(t *testing.T) {
|
||||
tool := NewWriteStdinTool(newExecSessionManager())
|
||||
for _, args := range []map[string]any{
|
||||
{},
|
||||
{"session_id": 0},
|
||||
want := UnknownExecSessionError(0)
|
||||
for name, args := range map[string]map[string]any{
|
||||
"missing": {},
|
||||
"nil": {"session_id": nil},
|
||||
"zero": {"session_id": 0},
|
||||
"negative": {"session_id": -3},
|
||||
"non-integer": {"session_id": "abc"},
|
||||
} {
|
||||
result := tool.Run(context.Background(), args)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("Run(%#v) status = %s, want error", args, result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Invalid arguments for write_stdin") {
|
||||
t.Fatalf("Run(%#v) output = %q, want invalid arguments", args, result.Output)
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result := tool.Run(context.Background(), args)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %s, want error", result.Status)
|
||||
}
|
||||
if result.Output != want {
|
||||
t.Fatalf("output = %q,\n want the recovery message %q", result.Output, want)
|
||||
}
|
||||
if strings.Contains(result.Output, "must be at least 1") {
|
||||
t.Fatalf("still returns the terse minimum error: %q", result.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -829,3 +829,61 @@ func TestGlobSkipsWorkspaceExcludedDirectoriesAndBinaryFiles(t *testing.T) {
|
||||
t.Fatalf("expected only keep.txt, got:\n%s", res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A backwards range (end_line < start_line) is a caller-arithmetic slip, not a
|
||||
// fatal error: read_file recovers by reading just start_line and surfaces a note
|
||||
// explaining the adjustment, matching how an end_line past EOF is auto-clamped.
|
||||
// It must never hard-error the way it used to.
|
||||
func TestReadFileToolRecoversBackwardsRange(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta\nepsilon")
|
||||
|
||||
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "notes.txt",
|
||||
"start_line": 3,
|
||||
"end_line": 2, // backwards
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("backwards range must recover, not error; got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "must be greater than or equal to start_line") {
|
||||
t.Fatalf("backwards range still hard-errors: %q", result.Output)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"end_line 2 was before start_line 3", // the note
|
||||
"only line 3 was read",
|
||||
"3 | gamma",
|
||||
} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected %q in output, got %q", want, result.Output)
|
||||
}
|
||||
}
|
||||
// It read exactly start_line, nothing adjacent.
|
||||
if strings.Contains(result.Output, "beta") || strings.Contains(result.Output, "delta") {
|
||||
t.Fatalf("recovery read the wrong lines: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A valid range with end_line >= start_line is byte-for-byte unchanged: the
|
||||
// recovery path must not touch normal reads (no stray note, exact same window).
|
||||
func TestReadFileToolValidRangeUnchangedByRecovery(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta\nepsilon")
|
||||
|
||||
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "notes.txt",
|
||||
"start_line": 2,
|
||||
"end_line": 4,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("valid range failed: %s: %s", result.Status, result.Output)
|
||||
}
|
||||
// Byte-for-byte: header, blank separator, the exact selected lines, and no
|
||||
// recovery note or trailing content. An altered header/separator or a stray
|
||||
// out-of-range line would fail this where a substring check would not.
|
||||
const want = "File: notes.txt (lines 2-4 of 5)\n\n2 | beta\n3 | gamma\n4 | delta"
|
||||
if result.Output != want {
|
||||
t.Fatalf("valid-range output changed by the recovery path:\n got: %q\nwant: %q", result.Output, want)
|
||||
}
|
||||
}
|
||||
@@ -106,8 +106,18 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
if endLine == 0 || endLine > total {
|
||||
endLine = total
|
||||
}
|
||||
// A backwards range (end_line < start_line) is a caller slip, not a fatal
|
||||
// error. Recover the way an end_line past EOF is already recovered a few lines
|
||||
// up (clamped down to the last line): clamp end_line UP to start_line and read
|
||||
// just that one line. Erroring here instead cost the caller a whole retry for
|
||||
// an off-by-arithmetic mistake, and it was inconsistent — start_line past EOF
|
||||
// and end_line past EOF both recover gracefully; only this case hard-failed.
|
||||
// The recovery is surfaced in the output (rangeNote) so it is never silent: the
|
||||
// caller sees it read a different range than it asked for and why.
|
||||
rangeNote := ""
|
||||
if endLine < startLine {
|
||||
return errorResult("Error: Invalid arguments for read_file: end_line must be greater than or equal to start_line")
|
||||
rangeNote = fmt.Sprintf("(note: end_line %d was before start_line %d, so only line %d was read — pass end_line >= start_line to read a wider range)", endLine, startLine, startLine)
|
||||
endLine = startLine
|
||||
}
|
||||
|
||||
truncated := false
|
||||
@@ -135,7 +145,12 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines to continue with a smaller range")
|
||||
}
|
||||
budgetedOutput.WriteString(header)
|
||||
budgetedOutput.WriteString("\n\n")
|
||||
budgetedOutput.WriteString("\n")
|
||||
if rangeNote != "" {
|
||||
budgetedOutput.WriteString(rangeNote)
|
||||
budgetedOutput.WriteString("\n")
|
||||
}
|
||||
budgetedOutput.WriteString("\n")
|
||||
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
|
||||
return errorResult("Error reading file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
|
||||
@@ -214,6 +214,12 @@ func (m model) setDoctorStatusRow(text string) model {
|
||||
for i := len(m.transcript) - 1; i >= 0; i-- {
|
||||
if m.transcript[i].id == doctorStatusRowID {
|
||||
m.transcript[i] = row
|
||||
// Settled alt-screen rows are cached across frames; force a rebuild
|
||||
// so an in-place update (e.g. the spinner tick) is reflected
|
||||
// immediately instead of serving the stale cached snapshot.
|
||||
if i < m.flushed {
|
||||
m.altScreenSettledWidth = 0
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
@@ -691,6 +697,14 @@ func (m model) persistSelectedModel(profile config.ProviderProfile) (bool, error
|
||||
if model == "" {
|
||||
return false, nil
|
||||
}
|
||||
persisted, err := config.ProviderPersisted(path, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !persisted {
|
||||
// Env-derived providers have no config.json row to update.
|
||||
return false, nil
|
||||
}
|
||||
if _, err := config.SetProviderModel(path, name, model); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@ var commandDefinitions = []commandDefinition{
|
||||
},
|
||||
{
|
||||
name: "/rewind",
|
||||
aliases: []string{"/undo"},
|
||||
usage: "/rewind [latest|<sequence>]",
|
||||
group: commandGroupSession,
|
||||
description: "Restore workspace files to a checkpoint and truncate the session.",
|
||||
|
||||
@@ -985,6 +985,31 @@ func TestParseBackgroundTerminalCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// /undo is an alias for /rewind (issue #698): previously it fell through to
|
||||
// commandUnknown.
|
||||
func TestUndoAliasesRewind(t *testing.T) {
|
||||
rewind, ok := resolveCommand("/rewind")
|
||||
if !ok {
|
||||
t.Fatal("expected /rewind to resolve")
|
||||
}
|
||||
alias, ok := resolveCommand("/undo")
|
||||
if !ok || alias.kind != rewind.kind {
|
||||
t.Fatalf("expected /undo to alias /rewind, got ok=%v command=%#v", ok, alias)
|
||||
}
|
||||
// End-to-end: a typed /undo dispatches to the rewind handler with args intact,
|
||||
// for both the "latest" and numeric-<sequence> forms of the /rewind contract.
|
||||
if parsed := parseCommand("/undo latest"); parsed.kind != commandRewind || parsed.text != "latest" {
|
||||
t.Fatalf("parseCommand(%q) = %#v, want commandRewind with text=latest", "/undo latest", parsed)
|
||||
}
|
||||
if parsed := parseCommand("/undo 123"); parsed.kind != commandRewind || parsed.text != "123" {
|
||||
t.Fatalf("parseCommand(%q) = %#v, want commandRewind with text=123", "/undo 123", parsed)
|
||||
}
|
||||
// Guard: without the alias it would be commandUnknown.
|
||||
if parsed := parseCommand("/undo"); parsed.kind == commandUnknown {
|
||||
t.Fatal("/undo resolved to commandUnknown — the alias is not wired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSelectionRequiresInputFromUsage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -348,12 +348,29 @@ func (m model) lastRowIndexForFile(path string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *model) setSelectedFile(path string) {
|
||||
if m.selectedFile == path {
|
||||
return
|
||||
}
|
||||
oldPath := m.selectedFile
|
||||
m.selectedFile = path
|
||||
for i := 0; i < m.flushed && i < len(m.transcript); i++ {
|
||||
for _, changedPath := range m.transcript[i].changedFiles {
|
||||
if changedPath == oldPath || changedPath == path {
|
||||
m.altScreenSettledWidth = 0
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// selectFile marks path as the selected file and scrolls the transcript so its
|
||||
// most recent edit card is in view; the card tint comes from the renderers
|
||||
// reading selectedFile (rowTouchesSelectedFile).
|
||||
func (m model) selectFile(path string) model {
|
||||
m.selectedFile = path
|
||||
if offset, ok := m.scrollOffsetForTranscriptRow(m.lastRowIndexForFile(path)); ok {
|
||||
rowIndex := m.lastRowIndexForFile(path)
|
||||
m.setSelectedFile(path)
|
||||
if offset, ok := m.scrollOffsetForTranscriptRow(rowIndex); ok {
|
||||
m.chatScrollOffset = offset
|
||||
if offset == 0 {
|
||||
m.chatBodyLines = 0
|
||||
|
||||
+61
-9
@@ -69,13 +69,6 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool {
|
||||
// of the unflushed tail is rendered once and queued for scrollback. It returns
|
||||
// the (possibly nil) print command for the queued batch.
|
||||
func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
// In alt-screen mode there is no native scrollback surface for tea.Println:
|
||||
// Bubble Tea drops that output by design. Keep rows in the managed view so
|
||||
// the chat behaves like a fullscreen app and cannot reveal prior shell
|
||||
// history by scrolling.
|
||||
if m.altScreen {
|
||||
return m, nil
|
||||
}
|
||||
// Never freeze history at the pre-WindowSizeMsg default width: the first
|
||||
// real WindowSizeMsg arrives before any user-visible content settles, and
|
||||
// flushing earlier would hard-wrap startup rows at 96 cols forever.
|
||||
@@ -87,10 +80,14 @@ func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
// frontier themselves; this is a safety net).
|
||||
m.flushed = len(m.transcript)
|
||||
}
|
||||
rc := buildRowContext(m.transcript)
|
||||
oldFlushed := m.flushed
|
||||
// An unsettled row blocks the frontier, so every cross-row dependency needed
|
||||
// to decide or render the tail (call/result and prompt/decision pairs) also
|
||||
// lives in the tail. Avoid rescanning the settled prefix on every tick.
|
||||
rc := buildRowContext(m.transcript[m.flushed:])
|
||||
width := chatWidth(m.width)
|
||||
batch := []string{}
|
||||
previousKind, havePreviousKind := previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
|
||||
previousKind, havePreviousKind := m.flushedPreviousKind, m.flushedHavePreviousKind
|
||||
for m.flushed < len(m.transcript) {
|
||||
row := m.transcript[m.flushed]
|
||||
if !m.settledRow(row, rc) {
|
||||
@@ -100,6 +97,15 @@ func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
if row.kind == rowWelcome || rc.skip(row) {
|
||||
continue
|
||||
}
|
||||
// Bubble Tea intentionally discards tea.Println in alt-screen mode. The
|
||||
// bookkeeping still advances; the settled prefix is retained in the body
|
||||
// item cache rebuilt below.
|
||||
if m.altScreen {
|
||||
m.flushedAny = true
|
||||
previousKind = row.kind
|
||||
havePreviousKind = true
|
||||
continue
|
||||
}
|
||||
rendered := m.renderRowMode(row, width, rc, true)
|
||||
if rendered == "" {
|
||||
continue
|
||||
@@ -115,6 +121,15 @@ func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
previousKind = row.kind
|
||||
havePreviousKind = true
|
||||
}
|
||||
m.flushedPreviousKind = previousKind
|
||||
m.flushedHavePreviousKind = havePreviousKind
|
||||
if m.altScreen {
|
||||
bodyWidth := m.chatColumnWidth()
|
||||
if m.flushed != oldFlushed || m.altScreenSettledWidth != bodyWidth || m.altScreenSettledFrontier != m.flushed {
|
||||
m.rebuildAltScreenSettledItems(bodyWidth)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
m.flushQueue = append(m.flushQueue, strings.Join(batch, "\n"))
|
||||
}
|
||||
@@ -139,6 +154,11 @@ func (m model) drainFlushQueue() (model, tea.Cmd) {
|
||||
// surface ended.
|
||||
func (m *model) resetFlushFrontier(divider string) {
|
||||
m.flushed = 0
|
||||
m.flushedPreviousKind = rowWelcome
|
||||
m.flushedHavePreviousKind = false
|
||||
m.altScreenSettledItems = nil
|
||||
m.altScreenSettledWidth = 0
|
||||
m.altScreenSettledFrontier = 0
|
||||
// Renumbers every row's bodyY from the top (used by /clear, /resume, /rewind,
|
||||
// /compact); a transcript-hover target's bodyY would otherwise risk
|
||||
// coincidentally matching an unrelated row in the rebuilt transcript.
|
||||
@@ -147,3 +167,35 @@ func (m *model) resetFlushFrontier(divider string) {
|
||||
m.flushQueue = append(m.flushQueue, zeroTheme.faint.Render(divider))
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildAltScreenSettledItems snapshots the stable prefix once when the
|
||||
// frontier (or width) changes. View then reuses these item descriptors and only
|
||||
// constructs descriptors/context for the live tail.
|
||||
func (m *model) rebuildAltScreenSettledItems(width int) {
|
||||
if !m.altScreen || m.flushed == 0 {
|
||||
m.altScreenSettledItems = nil
|
||||
m.altScreenSettledWidth = width
|
||||
m.altScreenSettledFrontier = m.flushed
|
||||
return
|
||||
}
|
||||
snapshot := *m
|
||||
snapshot.transcript = m.transcript[:m.flushed]
|
||||
snapshot.flushed = 0
|
||||
snapshot.flushedAny = false
|
||||
snapshot.flushedHavePreviousKind = false
|
||||
snapshot.altScreenSettledItems = nil
|
||||
snapshot.altScreenSettledWidth = 0
|
||||
snapshot.altScreenSettledFrontier = 0
|
||||
snapshot.pending = false
|
||||
snapshot.pendingSpecReview = nil
|
||||
snapshot.fileView = fileViewState{}
|
||||
built := snapshot.transcriptBodyItems(width, "", false)
|
||||
// Keep scratch capacity for the live tail. transcriptBodyItems can then
|
||||
// append transient rows without copying the entire settled prefix. The cache
|
||||
// length never includes those scratch entries, and the next frame overwrites
|
||||
// them on the single Bubble Tea render goroutine.
|
||||
m.altScreenSettledItems = make([]transcriptBodyItem, len(built), len(built)+256)
|
||||
copy(m.altScreenSettledItems, built)
|
||||
m.altScreenSettledWidth = width
|
||||
m.altScreenSettledFrontier = m.flushed
|
||||
}
|
||||
+147
-4
@@ -36,7 +36,7 @@ func TestSettledRowsAdvanceFrontierAndLeaveLiveView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) {
|
||||
func TestAltScreenAdvancesFrontierAndCachesSettledRows(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{AltScreen: true})
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
m = updated.(model)
|
||||
@@ -47,12 +47,155 @@ func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) {
|
||||
if cmd != nil {
|
||||
t.Fatal("alt-screen mode should not print rows into native scrollback")
|
||||
}
|
||||
if next.flushed != 0 {
|
||||
t.Fatalf("alt-screen mode should keep the flush frontier unchanged, got %d", next.flushed)
|
||||
if next.flushed != len(next.transcript) {
|
||||
t.Fatalf("alt-screen mode should advance the frontier to %d, got %d", len(next.transcript), next.flushed)
|
||||
}
|
||||
view := viewString(next.View())
|
||||
if !strings.Contains(view, "hello there") || !strings.Contains(view, "noted") {
|
||||
t.Fatalf("settled rows should remain in the managed alt-screen view, got %q", view)
|
||||
t.Fatalf("cached settled rows should remain in the managed alt-screen view, got %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheRebuildsAfterRowToggle(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{AltScreen: true})
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
m = updated.(model)
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowReasoning, text: "private thought"})
|
||||
m, _ = m.settleTranscript()
|
||||
|
||||
m = m.toggleTranscriptRow(len(m.transcript) - 1)
|
||||
m, _ = m.settleTranscript()
|
||||
if !m.transcript[len(m.transcript)-1].expanded {
|
||||
t.Fatal("expected reasoning row to be expanded")
|
||||
}
|
||||
if m.altScreenSettledWidth == 0 || len(m.altScreenSettledItems) == 0 {
|
||||
t.Fatal("expected invalidated settled cache to be rebuilt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheRebuildsAfterDoctorStatusUpdate(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{AltScreen: true})
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
m = updated.(model)
|
||||
m = m.setDoctorStatusRow("frame 0")
|
||||
m, _ = m.settleTranscript()
|
||||
if m.altScreenSettledWidth == 0 {
|
||||
t.Fatal("precondition: settled cache should be populated")
|
||||
}
|
||||
|
||||
// An in-place update to an already-settled doctor row (e.g. a spinner tick)
|
||||
// must invalidate the settled cache, just like toggleTranscriptRow does for
|
||||
// expand/collapse, otherwise the alt-screen view keeps serving the stale
|
||||
// snapshot forever.
|
||||
m = m.setDoctorStatusRow("frame 1")
|
||||
if m.altScreenSettledWidth != 0 {
|
||||
t.Fatal("expected in-place doctor status update to invalidate the settled cache")
|
||||
}
|
||||
m, _ = m.settleTranscript()
|
||||
view := viewString(m.View())
|
||||
if !strings.Contains(view, "frame 1") {
|
||||
t.Fatalf("expected updated doctor status text in view, got %q", view)
|
||||
}
|
||||
if strings.Contains(view, "frame 0") {
|
||||
t.Fatalf("stale doctor status text must not remain in view, got %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.altScreen = true
|
||||
m.width = 80
|
||||
m.height = 30
|
||||
m, _ = m.settleTranscript()
|
||||
if m.altScreenSettledWidth == 0 {
|
||||
t.Fatal("precondition: settled cache should be populated")
|
||||
}
|
||||
|
||||
m = m.selectFile("internal/tui/sidebar.go")
|
||||
if m.altScreenSettledWidth != 0 {
|
||||
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
|
||||
}
|
||||
m, _ = m.settleTranscript()
|
||||
selectedView := viewString(m.View())
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
||||
m = updated.(model)
|
||||
if m.selectedFile != "" {
|
||||
t.Fatalf("Esc should clear the selected file, got %q", m.selectedFile)
|
||||
}
|
||||
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
|
||||
t.Fatal("clearing a selected file should rebuild the invalidated cache")
|
||||
}
|
||||
if clearedView := viewString(m.View()); clearedView == selectedView {
|
||||
t.Fatal("clearing a selected file should remove the settled row's selection tint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
|
||||
const path = "internal/tui/sidebar.go"
|
||||
m := model{
|
||||
transcript: []transcriptRow{
|
||||
{kind: rowToolResult, changedFiles: []string{path}},
|
||||
{kind: rowToolResult, changedFiles: []string{path}},
|
||||
},
|
||||
flushed: 1,
|
||||
altScreenSettledWidth: 80,
|
||||
}
|
||||
|
||||
m.setSelectedFile(path)
|
||||
if m.altScreenSettledWidth != 0 {
|
||||
t.Fatal("selecting a file with an older settled card must invalidate the cache")
|
||||
}
|
||||
|
||||
m.altScreenSettledWidth = 80
|
||||
m.setSelectedFile("")
|
||||
if m.altScreenSettledWidth != 0 {
|
||||
t.Fatal("clearing a file with an older settled card must invalidate the cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenRepeatedStatusCollapseRewindsSettledFrontier(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{AltScreen: true})
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
m = updated.(model)
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.transcript = appendTranscriptRow(m.transcript,
|
||||
transcriptRow{kind: rowToolCall, id: "old", tool: "swarm_status", runID: 7},
|
||||
)
|
||||
m.transcript = appendTranscriptRow(m.transcript,
|
||||
transcriptRow{kind: rowToolResult, id: "old", tool: "swarm_status", status: tools.StatusOK, detail: "1 running", runID: 7},
|
||||
)
|
||||
m.transcript = appendTranscriptRow(m.transcript,
|
||||
transcriptRow{kind: rowToolCall, id: "new", tool: "swarm_status", runID: 7},
|
||||
)
|
||||
m, _ = m.settleTranscript()
|
||||
if m.flushed != len(m.transcript)-1 {
|
||||
t.Fatalf("precondition: frontier=%d, want %d", m.flushed, len(m.transcript)-1)
|
||||
}
|
||||
|
||||
updated, _ = m.Update(agentRowMsg{
|
||||
runID: 7,
|
||||
row: transcriptRow{
|
||||
kind: rowToolResult, id: "new", tool: "swarm_status", status: tools.StatusError, detail: "1 running", runID: 7,
|
||||
},
|
||||
})
|
||||
m = updated.(model)
|
||||
if len(m.transcript) != 3 {
|
||||
t.Fatalf("repeated status should collapse to welcome plus the new call/result pair, got %d rows", len(m.transcript))
|
||||
}
|
||||
if m.flushed != len(m.transcript) {
|
||||
t.Fatalf("collapsed replacement should settle through %d rows, got frontier %d", len(m.transcript), m.flushed)
|
||||
}
|
||||
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
|
||||
t.Fatal("collapsed replacement should rebuild the invalidated settled cache")
|
||||
}
|
||||
cachedView := viewString(m.View())
|
||||
rebuilt := m
|
||||
rebuilt.altScreenSettledWidth = 0
|
||||
rebuilt, _ = rebuilt.settleTranscript()
|
||||
if rebuiltView := viewString(rebuilt.View()); rebuiltView != cachedView {
|
||||
t.Fatal("collapsed replacement cache is stale compared with a forced rebuild")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-10
@@ -312,16 +312,23 @@ type model struct {
|
||||
chatBodyLines int
|
||||
|
||||
// Flush-frontier state (see flush.go). In inline mode, transcript[:flushed]
|
||||
// is already in native scrollback; in alt-screen mode this frontier stays
|
||||
// idle so history cannot reveal prior shell output.
|
||||
// is already in native scrollback. Alt-screen mode advances the same
|
||||
// frontier, but keeps the settled prefix as cached body items so fullscreen
|
||||
// scrolling still exposes the complete transcript without rebuilding it on
|
||||
// every frame.
|
||||
// flushedAny gates the first turn-separator blank line; flushQueue/
|
||||
// printInFlight serialize ordered scrollback prints; headerPrinted records
|
||||
// the one-time inline title-bar print at startup.
|
||||
flushed int
|
||||
flushedAny bool
|
||||
flushQueue []string
|
||||
printInFlight bool
|
||||
headerPrinted bool
|
||||
flushed int
|
||||
flushedAny bool
|
||||
flushedPreviousKind rowKind
|
||||
flushedHavePreviousKind bool
|
||||
flushQueue []string
|
||||
printInFlight bool
|
||||
headerPrinted bool
|
||||
altScreenSettledItems []transcriptBodyItem
|
||||
altScreenSettledWidth int
|
||||
altScreenSettledFrontier int
|
||||
|
||||
// Composer input history (shell-style ↑/↓ recall of submitted inputs).
|
||||
// lastPrompt is the verbatim text of the most recent submitted prompt, so
|
||||
@@ -401,8 +408,9 @@ type model struct {
|
||||
// mouseReleased, when true, forces terminal mouse capture OFF so the user can
|
||||
// drag-select and copy text natively (Ctrl+E toggles it). App mouse features
|
||||
// (clickable suggestions, right-click paste, transcript select) pause while on.
|
||||
mouseReleased bool
|
||||
transcriptSelection transcriptSelectionState
|
||||
mouseReleased bool
|
||||
transcriptSelection transcriptSelectionState
|
||||
transcriptInteraction *transcriptRenderInteraction
|
||||
// hover identifies the single clickable row (if any) currently under the
|
||||
// mouse cursor with no button pressed, so it renders in a distinct style —
|
||||
// the visual cue that it's clickable. Requires AllMotion mouse reporting
|
||||
@@ -841,6 +849,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
usageTracker: usageTracker,
|
||||
transcript: initialTranscript(),
|
||||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||||
transcriptInteraction: &transcriptRenderInteraction{},
|
||||
prService: prService,
|
||||
prState: prService.GetState(),
|
||||
input: input,
|
||||
@@ -1472,7 +1481,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// selection is a passive highlight, so Esc dropping it is cheap and
|
||||
// expected (mirrors how editors clear selection on Esc).
|
||||
if m.selectedFile != "" {
|
||||
m.selectedFile = ""
|
||||
m.setSelectedFile("")
|
||||
return m, nil
|
||||
}
|
||||
if m.hasQueuedMessage() {
|
||||
@@ -2469,7 +2478,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
// Collapse a repeated swarm status/collect card so re-checks don't flood
|
||||
// the chat with identical blocks.
|
||||
beforeCollapse := len(m.transcript)
|
||||
m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row)
|
||||
if removed := beforeCollapse - len(m.transcript); removed > 0 {
|
||||
m.flushed = max(0, m.flushed-removed)
|
||||
m.altScreenSettledWidth = 0
|
||||
}
|
||||
m.transcript = appendTranscriptRow(m.transcript, msg.row)
|
||||
m = m.captureStepWork(msg.row)
|
||||
// A finished command tool may have mutated files git can see but no
|
||||
|
||||
@@ -347,22 +347,42 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) {
|
||||
wizard.manageStatus = "No user config path — cannot delete."
|
||||
return m, nil
|
||||
}
|
||||
cfg, err := config.RemoveProvider(m.userConfigPath, name)
|
||||
|
||||
persisted, err := config.ProviderPersisted(m.userConfigPath, name)
|
||||
if err != nil {
|
||||
wizard.manageStatus = "Delete failed: " + err.Error()
|
||||
return m, nil
|
||||
}
|
||||
var notes []string
|
||||
var activeAfter string
|
||||
var cleanup tea.Cmd
|
||||
if persisted {
|
||||
cfg, err := config.RemoveProvider(m.userConfigPath, name)
|
||||
if err != nil {
|
||||
wizard.manageStatus = "Delete failed: " + err.Error()
|
||||
return m, nil
|
||||
}
|
||||
activeAfter = cfg.ActiveProvider
|
||||
notes = []string{"Deleted " + name + "."}
|
||||
cleanup = providerManagerCleanupCmd(m.userConfigPath, row.profile)
|
||||
} else {
|
||||
// Env-derived providers have no persisted profile or credential to
|
||||
// delete. Keep this path session-only.
|
||||
notes = []string{
|
||||
"Removed " + name + " from this session.",
|
||||
"It wasn't saved in config.json (likely set via an environment variable) — unset it to stop Zero from detecting it automatically.",
|
||||
}
|
||||
}
|
||||
|
||||
// Surgical removal — see saveManagerEdit for why the raw cfg.Providers list
|
||||
// must not replace the resolved/filtered savedProviders wholesale.
|
||||
m.savedProviders = removeSavedProvider(m.savedProviders, name)
|
||||
|
||||
notes := []string{"Deleted " + name + "."}
|
||||
if strings.EqualFold(strings.TrimSpace(m.providerName), strings.TrimSpace(name)) {
|
||||
notes = append(notes, "This session keeps running on it until you switch.")
|
||||
} else if active := strings.TrimSpace(cfg.ActiveProvider); active != "" && !strings.EqualFold(active, name) {
|
||||
notes = append(notes, "Active provider: "+active+".")
|
||||
} else if activeAfter != "" && !strings.EqualFold(activeAfter, name) {
|
||||
notes = append(notes, "Active provider: "+activeAfter+".")
|
||||
}
|
||||
cleanup := providerManagerCleanupCmd(m.userConfigPath, row.profile)
|
||||
|
||||
if len(m.savedProviders) == 0 {
|
||||
m.providerWizard = nil
|
||||
@@ -570,6 +590,15 @@ func (m model) saveManagerEdit() (model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
oldName := strings.TrimSpace(wizard.editOriginal.Name)
|
||||
persisted, err := config.ProviderPersisted(m.userConfigPath, oldName)
|
||||
if err != nil {
|
||||
wizard.err = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
if !persisted {
|
||||
wizard.err = "provider " + oldName + " is not saved in config.json, so there is no saved profile to edit"
|
||||
return m, nil
|
||||
}
|
||||
newName := strings.TrimSpace(wizard.editDraft.Name)
|
||||
if newName == "" {
|
||||
wizard.err = "name cannot be empty"
|
||||
|
||||
@@ -66,6 +66,11 @@ func rcKey(runID int, id string) string {
|
||||
}
|
||||
|
||||
func buildRowContext(rows []transcriptRow) rowContext {
|
||||
if len(rows) == 0 {
|
||||
// Nil maps are safe for all rowContext lookups. This is the steady-state
|
||||
// frontier-at-tail path, so avoid allocating maps on every View.
|
||||
return rowContext{}
|
||||
}
|
||||
rc := rowContext{
|
||||
resolved: map[string]bool{},
|
||||
hints: map[string]string{},
|
||||
|
||||
@@ -111,6 +111,12 @@ func (m model) setSandboxSetupStatusRow(text string) model {
|
||||
for i := len(m.transcript) - 1; i >= 0; i-- {
|
||||
if m.transcript[i].id == sandboxSetupStatusRowID {
|
||||
m.transcript[i] = row
|
||||
// Settled alt-screen rows are cached across frames; force a rebuild
|
||||
// so an in-place update is reflected immediately instead of serving
|
||||
// the stale cached snapshot.
|
||||
if i < m.flushed {
|
||||
m.altScreenSettledWidth = 0
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,6 +976,12 @@ func (m model) setCompactStatusRow(text string) model {
|
||||
for i := len(m.transcript) - 1; i >= 0; i-- {
|
||||
if m.transcript[i].id == compactStatusRowID {
|
||||
m.transcript[i] = row
|
||||
// Settled alt-screen rows are cached across frames; force a rebuild
|
||||
// so an in-place update (e.g. the compact spinner tick) is reflected
|
||||
// immediately instead of serving the stale cached snapshot.
|
||||
if i < m.flushed {
|
||||
m.altScreenSettledWidth = 0
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func benchmarkIssue561Model(turns int) model {
|
||||
m := transcriptViewTestModel()
|
||||
m.altScreen = true
|
||||
for i := 0; i < turns; i++ {
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowUser, text: fmt.Sprintf("question %d", i)},
|
||||
transcriptRow{kind: rowAssistant, text: fmt.Sprintf("answer %d", i), final: true},
|
||||
)
|
||||
}
|
||||
m, _ = m.settleTranscript()
|
||||
return m
|
||||
}
|
||||
|
||||
func BenchmarkIssue561SettledAltScreen(b *testing.B) {
|
||||
m := benchmarkIssue561Model(5000)
|
||||
width := m.chatColumnWidth()
|
||||
if m.altScreenSettledFrontier != m.flushed || m.altScreenSettledWidth != width {
|
||||
b.Fatalf(
|
||||
"settled cache miss before benchmark: frontier=%d flushed=%d width=%d wantWidth=%d",
|
||||
m.altScreenSettledFrontier,
|
||||
m.flushed,
|
||||
m.altScreenSettledWidth,
|
||||
width,
|
||||
)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = m.transcriptBodyItems(width, "", false)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -38,6 +39,29 @@ type transcriptSelectionState struct {
|
||||
cursor transcriptSelectionPoint
|
||||
}
|
||||
|
||||
// transcriptRenderInteraction lets settled-item render closures read the
|
||||
// current selection and hover state without rebuilding the cached descriptors.
|
||||
// The mutex keeps the shared cache state safe even if a renderer inspects a
|
||||
// model copy concurrently with an update.
|
||||
type transcriptRenderInteraction struct {
|
||||
mu sync.Mutex
|
||||
selection transcriptSelectionState
|
||||
hover hoverTarget
|
||||
}
|
||||
|
||||
func (s *transcriptRenderInteraction) set(selection transcriptSelectionState, hover hoverTarget) {
|
||||
s.mu.Lock()
|
||||
s.selection = selection
|
||||
s.hover = hover
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *transcriptRenderInteraction) get() (transcriptSelectionState, hoverTarget) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.selection, s.hover
|
||||
}
|
||||
|
||||
type transcriptSelectableLine struct {
|
||||
bodyY int
|
||||
rowIndex int
|
||||
@@ -167,6 +191,9 @@ func shiftSelectableX(lines []transcriptSelectableLine, gutter int) []transcript
|
||||
// so the highlight is computed in the same shifted coordinate the mouse maps to
|
||||
// and lands exactly where the user selected (instead of gutter cells off).
|
||||
func (m model) finalizeTranscriptBodyRow(rendered string, selectable []transcriptSelectableLine, gutter int, startBodyY int) transcriptBodyRenderedItem {
|
||||
if m.transcriptInteraction != nil {
|
||||
m.transcriptSelection, m.hover = m.transcriptInteraction.get()
|
||||
}
|
||||
lines := padTranscriptBodyLines(viewLines(rendered), gutter)
|
||||
shifted := shiftSelectableX(selectable, gutter)
|
||||
if m.transcriptSelection.active {
|
||||
@@ -211,6 +238,21 @@ func (m model) renderHoverHighlight(rendered string, selectable []transcriptSele
|
||||
}
|
||||
|
||||
func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool) []transcriptBodyItem {
|
||||
if m.transcriptInteraction != nil {
|
||||
m.transcriptInteraction.set(m.transcriptSelection, m.hover)
|
||||
}
|
||||
// Steady-state alt-screen frames (notably the idle cursor blink) can reuse
|
||||
// the settled list directly. Keeping this fast path outside the closure-heavy
|
||||
// builder also prevents the large model receiver from escaping to the heap.
|
||||
if m.altScreen && !detailed && !m.fileView.active && !m.pending && m.pendingSpecReview == nil &&
|
||||
m.flushedAny && m.flushed == len(m.transcript) &&
|
||||
m.altScreenSettledWidth == width && m.altScreenSettledFrontier == m.flushed {
|
||||
return m.altScreenSettledItems
|
||||
}
|
||||
return m.buildTranscriptBodyItems(width, emptyOverlay, detailed)
|
||||
}
|
||||
|
||||
func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed bool) []transcriptBodyItem {
|
||||
// File drill-in: the chat column's body swaps to the viewed file's
|
||||
// diff/content. Swapping HERE (the single source every consumer reads) keeps
|
||||
// the viewport, scroll engine, renderer, and mouse hit-tests consistent.
|
||||
@@ -239,7 +281,6 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool
|
||||
items = append(items, transcriptBlockBodyItem(transcriptBodyItemEmpty, -1, m.emptyState(width)))
|
||||
}
|
||||
} else {
|
||||
rc := buildRowContext(m.transcript)
|
||||
shownAny := false
|
||||
// The detailed view shows the full transcript from index 0, not
|
||||
// the managed region after m.flushed.
|
||||
@@ -247,11 +288,32 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool
|
||||
if detailed {
|
||||
startIdx = 0
|
||||
}
|
||||
useSettledCache := m.altScreen && !detailed &&
|
||||
m.altScreenSettledWidth == width &&
|
||||
m.altScreenSettledFrontier == m.flushed
|
||||
if useSettledCache {
|
||||
// The cache reserves scratch capacity for live-tail descriptors, avoiding
|
||||
// an O(history) slice copy on every streaming/spinner frame.
|
||||
items = m.altScreenSettledItems[:len(m.altScreenSettledItems)]
|
||||
} else if m.altScreen && !detailed {
|
||||
// A directly-constructed model (not yet passed through Update), or an
|
||||
// explicitly invalidated cache, must still render complete history.
|
||||
// The next settle rebuilds the cache for subsequent frames.
|
||||
startIdx = 0
|
||||
}
|
||||
// Dependencies for an unflushed row cannot live wholly before the
|
||||
// frontier: such a row would have blocked the frontier. Build context only
|
||||
// for the live tail instead of rescanning settled history each frame.
|
||||
rc := buildRowContext(m.transcript[startIdx:])
|
||||
renderRowFn := transcriptRowDispatchFn(m.renderTranscriptRow)
|
||||
if detailed {
|
||||
renderRowFn = transcriptRowDispatchFn(m.renderTranscriptDetailedRow)
|
||||
}
|
||||
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc)
|
||||
if startIdx > 0 {
|
||||
previousKind, havePreviousKind = m.flushedPreviousKind, m.flushedHavePreviousKind
|
||||
} else {
|
||||
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc)
|
||||
}
|
||||
specialistSummaryEmitted := false
|
||||
for index := startIdx; index < len(m.transcript); index++ {
|
||||
row := m.transcript[index]
|
||||
@@ -1326,7 +1388,7 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
// opens/switches the file view.
|
||||
if path, ok := m.fileRowAtMouse(msg); ok {
|
||||
if m.fileView.active || m.selectedFile == path {
|
||||
m.selectedFile = path
|
||||
m.setSelectedFile(path)
|
||||
return m.openFileView(path), nil, true
|
||||
}
|
||||
return m.selectFile(path), nil, true
|
||||
@@ -1442,6 +1504,11 @@ func (m model) toggleTranscriptRow(rowIndex int) model {
|
||||
switch m.transcript[rowIndex].kind {
|
||||
case rowReasoning, rowToolResult:
|
||||
m.transcript[rowIndex].expanded = !m.transcript[rowIndex].expanded
|
||||
// Settled alt-screen rows are cached across frames; force a rebuild so an
|
||||
// expand/collapse interaction is reflected immediately.
|
||||
if rowIndex < m.flushed {
|
||||
m.altScreenSettledWidth = 0
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user