mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df2cd633f9 | ||
|
|
f8c902c087 | ||
|
|
4fa0e5b0ba |
No files matched your search
@@ -195,7 +195,7 @@ Useful controls:
|
||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
|
||||
| `Ctrl+C` | cancel or exit |
|
||||
|
||||
Common slash commands:
|
||||
|
||||
@@ -205,7 +205,6 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
@@ -384,15 +383,6 @@ 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
|
||||
|
||||
@@ -56,10 +56,6 @@ type CompactionOptions struct {
|
||||
// injected so Compact stays pure and testable; the agent loop wires it to a
|
||||
// real provider call.
|
||||
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
||||
// taskState is a snapshot supplied by the running agent. Its immutable
|
||||
// objective is always preserved; mutable fields are admitted only when its
|
||||
// plan projection still matches the transcript.
|
||||
taskState *taskStateSnapshot
|
||||
}
|
||||
|
||||
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
||||
@@ -224,7 +220,7 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
|
||||
|
||||
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
||||
compacted = append(compacted, messages[:systemEnd]...)
|
||||
@@ -330,7 +326,6 @@ type compactionState struct {
|
||||
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
task *taskState
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
@@ -370,15 +365,13 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
return int(float64(raw) * state.calibrationRatio)
|
||||
}
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
func newCompactionState(options Options) *compactionState {
|
||||
return &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
@@ -425,7 +418,6 @@ func (state *compactionState) maybeCompact(
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
// Summarizer failed: keep the original history. The reactive path (or a
|
||||
@@ -476,7 +468,6 @@ func (state *compactionState) recover(
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
// A genuine compaction attempt was made (and failed): the budget is spent
|
||||
|
||||
@@ -30,10 +30,6 @@ const (
|
||||
const (
|
||||
maxRecentEdits = 20
|
||||
maxEditNoteBytes = 160
|
||||
// maxTaskObjectiveBytes keeps the original objective recognizable after
|
||||
// compaction without allowing an unusually large prompt to recreate the
|
||||
// context pressure the compaction just removed.
|
||||
maxTaskObjectiveBytes = 512
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -80,14 +76,32 @@ func extractLatestPlan(messages []zeroruntime.Message) string {
|
||||
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
|
||||
// "" on malformed arguments or an empty plan.
|
||||
func formatPlanArguments(arguments string) string {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Content string `json:"content"`
|
||||
Step string `json:"step"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
line := "- [" + item.Status + "] " + item.Content
|
||||
if notes := item.Notes; notes != "" {
|
||||
lines := make([]string, 0, len(parsed.Plan))
|
||||
for _, item := range parsed.Plan {
|
||||
content := strings.TrimSpace(item.Content)
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(item.Step)
|
||||
}
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
status := strings.TrimSpace(item.Status)
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
line := "- [" + status + "] " + content
|
||||
if notes := strings.TrimSpace(item.Notes); notes != "" {
|
||||
line += "\n Notes: " + notes
|
||||
}
|
||||
lines = append(lines, line)
|
||||
@@ -417,25 +431,12 @@ func capBody(body string) string {
|
||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||
type preservedState struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Task *preservedTaskState `json:"task,omitempty"`
|
||||
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||
Tools []preservedTool `json:"tools,omitempty"`
|
||||
Skills []preservedSkill `json:"skills,omitempty"`
|
||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||
}
|
||||
|
||||
type preservedTaskState struct {
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
Path string `json:"path"`
|
||||
Note string `json:"note,omitempty"`
|
||||
@@ -463,27 +464,8 @@ type preservedInstruction struct {
|
||||
// may live only inside the injected summary message, which on a later compaction
|
||||
// lands in middle with no real tool calls left to extract. Fresh tool calls and
|
||||
// instruction blocks override the carried-forward copy by name/source.
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnapshot *taskStateSnapshot) string {
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
}
|
||||
// Plan parity corroborates only the mutable task projection. The objective
|
||||
// comes directly from the run prompt and is immutable, so it must survive
|
||||
// even after compaction removes the plan tool call needed for comparison.
|
||||
if taskSnapshot.PlanParity == taskPlanParityMatch {
|
||||
task.Status = taskSnapshot.Status
|
||||
task.Pending = taskSnapshot.Plan.Pending
|
||||
task.InProgress = taskSnapshot.Plan.InProgress
|
||||
task.Completed = taskSnapshot.Plan.Completed
|
||||
task.Failed = taskSnapshot.Plan.Failed
|
||||
task.VerificationPassed = taskSnapshot.Verification.Passed
|
||||
task.VerificationFailed = taskSnapshot.Verification.Failed
|
||||
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
||||
}
|
||||
}
|
||||
|
||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||
// plan preserved by an earlier compaction.
|
||||
@@ -511,25 +493,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
projectInstructionEntries(middle),
|
||||
)
|
||||
|
||||
if block := formatPreservedState(plan, task, edits, tools, skills, instructions); block != "" {
|
||||
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
|
||||
summary += "\n\n" + block
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func capTaskObjective(objective string) string {
|
||||
objective = strings.TrimSpace(objective)
|
||||
if len(objective) <= maxTaskObjectiveBytes {
|
||||
return objective
|
||||
}
|
||||
const suffix = "…"
|
||||
limit := maxTaskObjectiveBytes - len(suffix)
|
||||
for limit > 0 && !utf8.RuneStart(objective[limit]) {
|
||||
limit--
|
||||
}
|
||||
return strings.TrimSpace(objective[:limit]) + suffix
|
||||
}
|
||||
|
||||
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
||||
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
||||
// original slot), a path touched again by a fresh edit MOVES to the newest
|
||||
@@ -586,11 +555,11 @@ func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
||||
|
||||
// formatPreservedState renders state as the labelled, single-line
|
||||
// JSON block. Returns "" when there is nothing to preserve.
|
||||
func formatPreservedState(plan string, task *preservedTaskState, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && task == nil && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
return ""
|
||||
}
|
||||
state := preservedState{Plan: plan, Task: task}
|
||||
state := preservedState{Plan: plan}
|
||||
for _, e := range edits {
|
||||
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
@@ -60,103 +59,6 @@ func TestCompactPreservesActivePlan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
objective := strings.Repeat("世", maxTaskObjectiveBytes)
|
||||
task := newTaskState(objective, nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Status != taskStatusActive || state.Task.InProgress != 1 || state.Task.Pending != 1 {
|
||||
t.Fatalf("unexpected compact task state: %#v", state.Task)
|
||||
}
|
||||
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
||||
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
||||
prior := preservedState{Task: &preservedTaskState{Objective: "stale objective", Status: taskStatusActive, Pending: 1}}
|
||||
encoded, err := json.Marshal(prior)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\nold\n\n" + preservedStateLabel + "\n" + string(encoded)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
}
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: &taskStateSnapshot{
|
||||
Objective: "current objective",
|
||||
PlanParity: taskPlanParityMismatch,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "current objective" {
|
||||
t.Fatalf("immutable objective was lost on plan mismatch: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.Pending != 0 {
|
||||
t.Fatalf("uncorroborated mutable task fields survived plan mismatch: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskObjectiveSurvivesRepeatedCompactionWithoutPlanRefresh(t *testing.T) {
|
||||
task := newTaskState("keep this objective", nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
|
||||
first, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Compact: %v", err)
|
||||
}
|
||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
)
|
||||
snapshot := task.snapshotForCompaction(secondInput)
|
||||
if snapshot.PlanParity != taskPlanParityMismatch {
|
||||
t.Fatalf("plan call should be absent after first compaction, parity=%q", snapshot.PlanParity)
|
||||
}
|
||||
second, err := Compact(secondInput, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||
taskState: snapshot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(second[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "keep this objective" {
|
||||
t.Fatalf("objective lost after second compaction: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.InProgress != 0 {
|
||||
t.Fatalf("uncorroborated mutable fields survived second compaction: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesLoadedSkills(t *testing.T) {
|
||||
summary := compactStateConversation(t, stateConversation())
|
||||
if !strings.Contains(summary, preservedStateLabel) {
|
||||
@@ -381,10 +283,12 @@ func TestExtractLatestPlanReturnsMostRecent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPlanArgumentsRejectsUnsupportedStepAlias(t *testing.T) {
|
||||
func TestFormatPlanArgumentsAcceptsStepAlias(t *testing.T) {
|
||||
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
|
||||
if got != "" {
|
||||
t.Fatalf("unsupported alias should reject the whole plan like update_plan, got %q", got)
|
||||
for _, want := range []string{"- [in_progress] write failing test", "- [pending] keep existing shape"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q in formatted plan, got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -581,7 +581,7 @@ func TestCompactNeverProducesConsecutiveUserMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
@@ -628,7 +628,7 @@ func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverDisabledIsNoop(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 0}, nil)
|
||||
st := newCompactionState(Options{ContextWindow: 0})
|
||||
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
|
||||
called := false
|
||||
// recover must not invoke the provider/summarize when disabled, even on a
|
||||
|
||||
@@ -39,7 +39,7 @@ func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
|
||||
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
|
||||
}
|
||||
|
||||
func (policy *completionPolicy) evaluate(text string, context completionContext) completionEvaluation {
|
||||
func (policy *completionPolicy) evaluate(text string, planPending bool) completionEvaluation {
|
||||
// A direct admission is strong local evidence and takes precedence over all
|
||||
// weaker signals, avoiding both continuation nudges and semantic checks.
|
||||
if reason := selfReportedIncompletion(text); reason != "" {
|
||||
@@ -50,7 +50,7 @@ func (policy *completionPolicy) evaluate(text string, context completionContext)
|
||||
// weak evidence because bookkeeping can be stale. Both get a bounded chance
|
||||
// to continue, but only a persisted cue is ultimately incomplete.
|
||||
cue := endsWithContinuationCue(text)
|
||||
if cue || context.PlanPending {
|
||||
if cue || planPending {
|
||||
if policy.continueNudges < maxContinueNudges {
|
||||
policy.continueNudges++
|
||||
reason := "your message ended mid-step"
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
|
||||
complete := policy.evaluate("Done. All required checks pass.", completionContext{})
|
||||
complete := policy.evaluate("Done. All required checks pass.", false)
|
||||
if complete.Decision != CompletionComplete {
|
||||
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
|
||||
}
|
||||
|
||||
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", completionContext{})
|
||||
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", false)
|
||||
if incomplete.Decision != CompletionIncomplete {
|
||||
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
|
||||
}
|
||||
@@ -22,13 +19,13 @@ func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T)
|
||||
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
||||
if got.Decision != CompletionIncomplete {
|
||||
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
|
||||
}
|
||||
@@ -37,13 +34,13 @@ func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
||||
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||
got := policy.evaluate("All set.", true)
|
||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||
got := policy.evaluate("All set.", true)
|
||||
if got.Decision != CompletionComplete {
|
||||
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
|
||||
}
|
||||
@@ -52,12 +49,12 @@ func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
||||
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
||||
policy := newCompletionPolicy(true)
|
||||
|
||||
first := policy.evaluate("Implemented and tested.", completionContext{})
|
||||
first := policy.evaluate("Implemented and tested.", false)
|
||||
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
|
||||
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
|
||||
}
|
||||
|
||||
second := policy.evaluate("PASS. The result meets the task criterion.", completionContext{})
|
||||
second := policy.evaluate("PASS. The result meets the task criterion.", false)
|
||||
if second.Decision != CompletionComplete {
|
||||
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
|
||||
}
|
||||
@@ -65,11 +62,3 @@ func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
||||
t.Fatal("semantic check was requested more than once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceVerificationNudgeIncludesBoundedObjective(t *testing.T) {
|
||||
objective := "make the requested behavior observable"
|
||||
nudge := acceptanceVerificationNudge(objective)
|
||||
if !strings.Contains(nudge, "Task objective: "+objective) {
|
||||
t.Fatalf("semantic check was not grounded in the objective: %q", nudge)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -126,8 +127,9 @@ func endsWithContinuationCue(text string) bool {
|
||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||
// that tool's own status normalization.
|
||||
func planStatusRemaining(status string) bool {
|
||||
switch normalizeTaskPlanStatus(status) {
|
||||
case "completed", "failed":
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
|
||||
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -305,8 +307,8 @@ func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||
// passing treated as the objective being met; and a result that merely matches a
|
||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||
func acceptanceVerificationNudge(objective string) string {
|
||||
nudge := "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
func acceptanceVerificationNudge() string {
|
||||
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||
"result merely matches a baseline you were asked to beat or improve. " +
|
||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||
@@ -314,10 +316,6 @@ func acceptanceVerificationNudge(objective string) string {
|
||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||
"If that check passes, reply PASS and cite the evidence. " +
|
||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||
if objective = capTaskObjective(objective); objective != "" {
|
||||
nudge += "\n\nTask objective: " + objective
|
||||
}
|
||||
return nudge
|
||||
}
|
||||
|
||||
// toolFailureHintMarker is a stable substring for tests.
|
||||
@@ -555,12 +553,16 @@ func (state *guardState) pendingPlanItems() bool {
|
||||
// many items are still remaining. Malformed arguments leave the prior count
|
||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||
func (state *guardState) observePlanUpdate(arguments string) {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if json.Unmarshal([]byte(arguments), &parsed) != nil {
|
||||
return
|
||||
}
|
||||
pending := 0
|
||||
for _, item := range plan {
|
||||
for _, item := range parsed.Plan {
|
||||
if planStatusRemaining(item.Status) {
|
||||
pending++
|
||||
}
|
||||
|
||||
@@ -169,37 +169,6 @@ 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{
|
||||
|
||||
+12
-31
@@ -181,20 +181,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.runPermissions = runPermissions
|
||||
defer runPermissions.cleanup()
|
||||
|
||||
promptParts := buildSystemPromptParts(options)
|
||||
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
|
||||
messages := zeroruntime.SeedMessagesWithImages(buildSystemPrompt(options), prompt, options.Images)
|
||||
|
||||
guards := newGuardState()
|
||||
task := newTaskState(prompt, options.Trace)
|
||||
compactor := newCompactionState(options, task)
|
||||
defer func() {
|
||||
// A final transcript comparison is observational. It records drift but
|
||||
// never rewrites the result or changes execution after the fact.
|
||||
task.observePlanParity(result.Messages)
|
||||
// Tool-result observations are intentionally coalesced; this final event
|
||||
// guarantees their aggregate counts are present even on an early return.
|
||||
task.emit()
|
||||
}()
|
||||
compactor := newCompactionState(options)
|
||||
// The execution-profile posture controller. nil Options.Profile (every
|
||||
// existing caller) makes every observe/decide call a no-op, keeping the
|
||||
// loop byte-identical for unprofiled runs.
|
||||
@@ -278,14 +268,16 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
toolPartitionSpan.End()
|
||||
|
||||
// Fingerprint the exact system prompt built at run start plus this turn's
|
||||
// provider-visible tool definitions in their emitted order. Reusing the
|
||||
// retained prompt parts avoids rebuilding workspace context while ensuring
|
||||
// the trace describes the same bytes carried by the request.
|
||||
// Prompt-prefix fingerprint: hash the seven cacheable sub-components
|
||||
// of this turn's request (system prompt sections, project context,
|
||||
// skills, partitioned tool list, tool schemas) and emit one trace
|
||||
// event. Stable across turns => cacheable. Drift in any sub-hash
|
||||
// names the sub-component that broke the cache for this turn. The
|
||||
// computation is pure and cheap (seven SHA-256s of small strings)
|
||||
// and is a no-op when tracing is off.
|
||||
if options.Trace != nil {
|
||||
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
|
||||
fp := ComputePrefixFingerprint(options, exposed)
|
||||
options.Trace.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fp.SystemPromptHash,
|
||||
BaseInstructionsHash: fp.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fp.ProjectContextHash,
|
||||
@@ -579,9 +571,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// model's final answer ONLY when the work is actually done. Default off
|
||||
// (RequireCompletionSignal), so interactive runs stay byte-identical.
|
||||
if options.RequireCompletionSignal {
|
||||
completionContext := task.completionContext(messages, guards.pendingPlanItems())
|
||||
evaluation := completionPolicy.evaluate(collected.Text, completionContext)
|
||||
task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: evaluation})
|
||||
evaluation := completionPolicy.evaluate(collected.Text, guards.pendingPlanItems())
|
||||
switch evaluation.Decision {
|
||||
case CompletionIncomplete:
|
||||
result.Incomplete = true
|
||||
@@ -606,15 +596,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.Trace.Counter(trace.CounterAcceptanceChecks, 1)
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: acceptanceVerificationNudge(completionContext.Objective),
|
||||
Content: acceptanceVerificationNudge(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
case CompletionComplete:
|
||||
// Local evidence is sufficient; proceed to final diagnostics.
|
||||
}
|
||||
} else {
|
||||
task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
||||
}
|
||||
// Finalization diagnostics gate: edits from this run may still have
|
||||
// checks in flight — the per-turn drain waits only briefly and defers
|
||||
@@ -638,11 +626,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// executing so the empty-turn counter resets and plan-tracking signals
|
||||
// stay current.
|
||||
guards.observeTurn(collected)
|
||||
for _, call := range collected.ToolCalls {
|
||||
if call.Name == planToolName {
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: call.Arguments})
|
||||
}
|
||||
}
|
||||
|
||||
failureHint := ""
|
||||
// turnRequestedModel records the FIRST mid-run escalation target requested
|
||||
@@ -694,7 +677,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterToolCalls, 1)
|
||||
recordOutputBudgetTrace(options.Trace, toolResult)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -776,7 +758,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
if options.SelfCorrect != nil && len(changedFilesThisBatch) > 0 {
|
||||
feedback, selfCorrectOutcome := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch))
|
||||
posture.observeSelfCorrect(selfCorrectOutcome)
|
||||
task.observe(taskStateEvent{kind: taskStateEventVerification, verification: selfCorrectOutcome})
|
||||
if feedback != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1259,46 +1258,6 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesRequestPrefixAcrossTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
if _, err := Run(context.Background(), "read notes", provider, Options{
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
SessionID: "session-stable-prefix",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("provider requests = %d, want 2", len(provider.requests))
|
||||
}
|
||||
first, second := provider.requests[0], provider.requests[1]
|
||||
if len(second.Messages) < len(first.Messages) || !reflect.DeepEqual(second.Messages[:len(first.Messages)], first.Messages) {
|
||||
t.Fatalf("first request messages must be an exact prefix of the second:\nfirst=%#v\nsecond=%#v", first.Messages, second.Messages)
|
||||
}
|
||||
if !reflect.DeepEqual(first.Tools, second.Tools) {
|
||||
t.Fatalf("tool definitions drifted between turns:\nfirst=%#v\nsecond=%#v", first.Tools, second.Tools)
|
||||
}
|
||||
if first.PromptCacheKey != "session-stable-prefix" || second.PromptCacheKey != first.PromptCacheKey {
|
||||
t.Fatalf("prompt cache key must remain stable: first=%q second=%q", first.PromptCacheKey, second.PromptCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
@@ -11,11 +11,12 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// promptSubstrings are the cacheable components of the prompt a run sends to a
|
||||
// model. systemPrompt is the exact joined system-message content. The remaining
|
||||
// fields retain useful diagnostic boundaries within that prompt and tool list.
|
||||
// promptSubstrings are the seven cacheable sub-components of the prompt a run
|
||||
// sends to a model. Each is the literal content the sub-component would emit
|
||||
// into the system prompt or tool list, hashed so a downstream observer can
|
||||
// detect drift turn-over-turn. The substrings are produced by
|
||||
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
|
||||
type promptSubstrings struct {
|
||||
systemPrompt string
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
@@ -24,55 +25,92 @@ type promptSubstrings struct {
|
||||
schema string
|
||||
}
|
||||
|
||||
// buildPromptSubstrings builds the prompt and retains both its exact joined
|
||||
// form and narrower diagnostic components. Callers that already built the
|
||||
// prompt should use buildPromptSubstringsFromParts to avoid repeating workspace
|
||||
// reads.
|
||||
// buildPromptSubstrings assembles the seven cacheable sub-components of the
|
||||
// prompt without joining them. The system prompt sections are produced by the
|
||||
// same private builders that buildSystemPrompt uses, so the substrings are
|
||||
// byte-identical to what would have appeared in the joined prompt. The tools
|
||||
// and schema substrings are derived from the partitioned tool list (caller-
|
||||
// provided) so this helper does not need to re-run the partition.
|
||||
//
|
||||
// This is the seam ComputePrefixFingerprint reads from. It exists as a
|
||||
// separate function (rather than returning both prompt and substrings from
|
||||
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
|
||||
// and the substrings helper is independently testable.
|
||||
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
return buildPromptSubstringsFromParts(buildSystemPromptParts(options), exposed)
|
||||
}
|
||||
core := strings.TrimSpace(options.SystemPrompt)
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(coreSystemPrompt)
|
||||
}
|
||||
if core == "" {
|
||||
core = fallbackSystemPrompt
|
||||
}
|
||||
|
||||
func buildPromptSubstringsFromParts(parts systemPromptParts, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
// The confirmation policy is appended unconditionally in buildSystemPrompt;
|
||||
// the substring here is the same constant.
|
||||
policy := strings.TrimSpace(confirmationPolicy)
|
||||
|
||||
// projectContext is the joined output of workspaceContext, which itself
|
||||
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
|
||||
// joined string captures every guideline file's contribution and the
|
||||
// ordering between them.
|
||||
project := workspaceContext(options.Cwd)
|
||||
|
||||
// skills is the joined output of skillsContext, with each skill on its own
|
||||
// line. The substring is the literal text the model would see.
|
||||
skills := skillsContext(options)
|
||||
|
||||
// tools and schema are derived from the partitioned tool list. The list
|
||||
// passed in is already in stable order (partitionToolsCached is the
|
||||
// canonical stable partitioner) so concatenating in slice order is
|
||||
// sufficient — we sort names again defensively in case a future caller
|
||||
// hands us a list from a different partitioner.
|
||||
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
||||
|
||||
return promptSubstrings{
|
||||
systemPrompt: parts.prompt,
|
||||
baseInstructions: parts.baseInstructions,
|
||||
confirmationPolicy: parts.confirmationPolicy,
|
||||
projectContext: parts.projectContext,
|
||||
skills: parts.skills,
|
||||
baseInstructions: core,
|
||||
confirmationPolicy: policy,
|
||||
projectContext: project,
|
||||
skills: skills,
|
||||
tools: toolsSubstr,
|
||||
schema: schemaSubstr,
|
||||
}
|
||||
}
|
||||
|
||||
// toolSubstrings preserves the emitted tool order. Names and descriptions are
|
||||
// fingerprinted separately from schemas so either kind of drift is identifiable.
|
||||
// Order is part of request identity: silently sorting here would hide a provider-
|
||||
// visible reordering and could report a false cache hit.
|
||||
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
|
||||
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
|
||||
// the partition produces a different hash. The schema substring is the
|
||||
// concatenation of each tool's JSON schema in name order, so a schema change
|
||||
// is detected independently of the tool ordering.
|
||||
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
||||
if len(exposed) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
var toolsSB, schemaSB strings.Builder
|
||||
names := make([]string, 0, len(exposed))
|
||||
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
|
||||
for _, def := range exposed {
|
||||
writeLengthPrefixed(&toolsSB, def.Name)
|
||||
writeLengthPrefixed(&toolsSB, def.Description)
|
||||
names = append(names, def.Name)
|
||||
byName[def.Name] = def
|
||||
}
|
||||
sort.Strings(names)
|
||||
var toolsSB, schemaSB strings.Builder
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
toolsSB.WriteByte('\n')
|
||||
}
|
||||
toolsSB.WriteString(name)
|
||||
def := byName[name]
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
writeLengthPrefixed(&schemaSB, def.Name)
|
||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||
schemaSB.WriteString(name)
|
||||
schemaSB.WriteByte('\n')
|
||||
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
|
||||
schemaSB.WriteByte('\n')
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
func writeLengthPrefixed(sb *strings.Builder, value string) {
|
||||
fmt.Fprintf(sb, "%d:", len(value))
|
||||
sb.WriteString(value)
|
||||
}
|
||||
|
||||
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
||||
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
||||
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
||||
@@ -189,16 +227,36 @@ func writeStable(sb *strings.Builder, v any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ComputePrefixFingerprint builds and fingerprints a prompt for callers that do
|
||||
// not already have one. The agent loop uses computePrefixFingerprint with the
|
||||
// prompt parts it built once at run start, ensuring tracing observes the exact
|
||||
// request content without repeating workspace I/O.
|
||||
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
||||
return computePrefixFingerprint(buildPromptSubstrings(options, exposed))
|
||||
}
|
||||
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
|
||||
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
|
||||
// independent SHA-256s of the corresponding sub-component; the complete-prefix
|
||||
// hash is a SHA-256 of the canonical concatenation of the other six, so any
|
||||
// sub-component drift is observable both individually and in aggregate.
|
||||
//
|
||||
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
|
||||
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
|
||||
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
|
||||
// context work is performed separately by buildSystemPrompt a few lines
|
||||
// later in the request-build path, so on every traced turn the file and
|
||||
// git reads happen twice. The duplication is intentional for this PR (a
|
||||
// follow-up will pass substrings through) and is a known perf cost only on
|
||||
// the opt-in trace path.
|
||||
//
|
||||
// A stable CompletePrefixHash across turns means the four captured
|
||||
// sub-components (baseInstructions, confirmationPolicy, projectContext,
|
||||
// skills) are byte-identical. It does NOT rule out drift in the seven
|
||||
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
|
||||
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
|
||||
// userGuidelines, specialistDelegationContext, responseStyleContext). For
|
||||
// default Options the four captured substrings are the full prompt; for
|
||||
// non-default Options the seven uncaptured sections may contribute, and a
|
||||
// consumer correlating CompletePrefix stability with cached_input_tokens
|
||||
// must cross-check the model_switches counter (modelPromptAddendum changes
|
||||
// on a model switch) to disambiguate "no drift" from "drift in an
|
||||
// uncaptured section."
|
||||
|
||||
func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
||||
systemPrompt := sha256hex(subs.systemPrompt)
|
||||
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
||||
subs := buildPromptSubstrings(options, exposed)
|
||||
base := sha256hex(subs.baseInstructions)
|
||||
policy := sha256hex(subs.confirmationPolicy)
|
||||
project := sha256hex(subs.projectContext)
|
||||
@@ -206,10 +264,9 @@ func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
||||
toolsH := sha256hex(subs.tools)
|
||||
schema := sha256hex(subs.schema)
|
||||
complete := sha256hex(strings.Join([]string{
|
||||
systemPrompt, toolsH, schema,
|
||||
base, policy, project, skills, toolsH, schema,
|
||||
}, "|"))
|
||||
return prefixFingerprint{
|
||||
SystemPromptHash: systemPrompt,
|
||||
BaseInstructionsHash: base,
|
||||
ConfirmationPolicyHash: policy,
|
||||
ProjectContextHash: project,
|
||||
@@ -225,7 +282,6 @@ func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
||||
// in loop.go) so the trace package does not need to import this one. The field
|
||||
// names match the trace.PrefixHash JSON tags 1:1.
|
||||
type prefixFingerprint struct {
|
||||
SystemPromptHash string
|
||||
BaseInstructionsHash string
|
||||
ConfirmationPolicyHash string
|
||||
ProjectContextHash string
|
||||
|
||||
@@ -28,28 +28,38 @@ func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tool order and descriptions are provider-visible and therefore part of the
|
||||
// cacheable prefix identity.
|
||||
func TestComputePrefixFingerprintToolsHashMatchesEmittedOrderAndDescriptions(t *testing.T) {
|
||||
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
|
||||
// name-set sensitivity of the ToolsHash. The test name used to read as
|
||||
// "reorder changes hash" but the assertion is the opposite: the hash is
|
||||
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
|
||||
// names before hashing). The partitioner is expected to produce a stable
|
||||
// order, so a name-order change is not a hash signal — a name-set change
|
||||
// is. The test asserts both: permutation of the same tools produces the
|
||||
// same hash, and adding a tool produces a different hash.
|
||||
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
a := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
}
|
||||
b := []zeroruntime.ToolDefinition{
|
||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
}
|
||||
// toolSubstrings sorts names internally, so the ToolsHash is identical
|
||||
// for a permutation of the same tools. We assert that here to document
|
||||
// the contract: name-order independence, name-set sensitivity.
|
||||
fpA := ComputePrefixFingerprint(opts, a)
|
||||
fpB := ComputePrefixFingerprint(opts, b)
|
||||
if fpA.ToolsHash == fpB.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when emitted order changes: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||
if fpA.ToolsHash != fpB.ToolsHash {
|
||||
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||
}
|
||||
c := append([]zeroruntime.ToolDefinition(nil), a...)
|
||||
c[0].Description = "read a file"
|
||||
// A different tool set must produce a different ToolsHash.
|
||||
c := append([]zeroruntime.ToolDefinition{}, a...)
|
||||
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
|
||||
fpC := ComputePrefixFingerprint(opts, c)
|
||||
if fpA.ToolsHash == fpC.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when a description changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,35 +101,25 @@ func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// CompletePrefixHash represents the exact joined system prompt and the ordered
|
||||
// tool definitions. Narrower system-section hashes remain diagnostic only.
|
||||
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
|
||||
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
|
||||
// This is the regression catch for "did anyone reorder or drop a sub-hash
|
||||
// from the canonical join without updating CompletePrefixHash."
|
||||
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
||||
fp := ComputePrefixFingerprint(opts, exposed)
|
||||
// Manually compute what the canonical join should be.
|
||||
expected := sha256hex(strings.Join([]string{
|
||||
fp.SystemPromptHash,
|
||||
fp.BaseInstructionsHash,
|
||||
fp.ConfirmationPolicyHash,
|
||||
fp.ProjectContextHash,
|
||||
fp.SkillsHash,
|
||||
fp.ToolsHash,
|
||||
fp.SchemaHash,
|
||||
}, "|"))
|
||||
if fp.CompletePrefixHash != expected {
|
||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the exact system, tools, and schema hashes:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
|
||||
base := Options{Cwd: t.TempDir(), SystemPrompt: "core", Model: "gpt-5"}
|
||||
changed := base
|
||||
changed.ResponseStyle = "concise"
|
||||
|
||||
fpBase := ComputePrefixFingerprint(base, nil)
|
||||
fpChanged := ComputePrefixFingerprint(changed, nil)
|
||||
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
||||
t.Fatal("SystemPromptHash must change when any rendered system section changes")
|
||||
}
|
||||
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
||||
t.Fatal("CompletePrefixHash must include the exact full system prompt")
|
||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,9 +141,6 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
||||
if subs.baseInstructions != "test core" {
|
||||
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
||||
}
|
||||
if subs.systemPrompt != buildSystemPrompt(opts) {
|
||||
t.Fatal("systemPrompt substring must equal the exact joined system prompt")
|
||||
}
|
||||
if subs.confirmationPolicy == "" {
|
||||
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
||||
}
|
||||
@@ -154,9 +151,6 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
||||
// sha256hex of the substrings, with no truncation or reformatting
|
||||
// between the two layers.
|
||||
fp := ComputePrefixFingerprint(opts, nil)
|
||||
if fp.SystemPromptHash != sha256hex(subs.systemPrompt) {
|
||||
t.Fatalf("SystemPromptHash in fingerprint must equal sha256hex of the joined prompt")
|
||||
}
|
||||
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
||||
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
||||
}
|
||||
|
||||
@@ -70,22 +70,6 @@ const (
|
||||
// guidelines), and the safety confirmation policy. It is built once per run so
|
||||
// every turn shares one (cacheable) system turn.
|
||||
func buildSystemPrompt(options Options) string {
|
||||
return buildSystemPromptParts(options).prompt
|
||||
}
|
||||
|
||||
// systemPromptParts retains both the exact joined prompt and the diagnostic
|
||||
// sections used by prompt-prefix tracing. Building these together prevents the
|
||||
// tracing path from re-reading workspace state and guarantees that the hash is
|
||||
// computed from the same prompt bytes placed in the request.
|
||||
type systemPromptParts struct {
|
||||
prompt string
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
skills string
|
||||
}
|
||||
|
||||
func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
core := strings.TrimSpace(options.SystemPrompt)
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(coreSystemPrompt)
|
||||
@@ -113,31 +97,22 @@ func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
if user := userGuidelines(); user != "" {
|
||||
sections = append(sections, user)
|
||||
}
|
||||
project := workspaceContext(options.Cwd)
|
||||
if project != "" {
|
||||
sections = append(sections, project)
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
}
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
skillsBlock := skillsContext(options)
|
||||
if skillsBlock != "" {
|
||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||
sections = append(sections, skillsBlock)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
policy := strings.TrimSpace(confirmationPolicy)
|
||||
if policy != "" {
|
||||
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
|
||||
sections = append(sections, policy)
|
||||
}
|
||||
return systemPromptParts{
|
||||
prompt: strings.Join(sections, "\n\n"),
|
||||
baseInstructions: core,
|
||||
confirmationPolicy: policy,
|
||||
projectContext: project,
|
||||
skills: skillsBlock,
|
||||
}
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
// responseStyleContext renders the operator-selected reply style (TUI /style) as
|
||||
|
||||
@@ -100,9 +100,6 @@ 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
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type taskStatus string
|
||||
|
||||
const (
|
||||
taskStatusActive taskStatus = "active"
|
||||
taskStatusComplete taskStatus = "complete"
|
||||
taskStatusIncomplete taskStatus = "incomplete"
|
||||
)
|
||||
|
||||
type taskPlanParity string
|
||||
|
||||
const (
|
||||
taskPlanParityUnknown taskPlanParity = "unknown"
|
||||
taskPlanParityMatch taskPlanParity = "match"
|
||||
taskPlanParityMismatch taskPlanParity = "mismatch"
|
||||
)
|
||||
|
||||
type taskPlanItem struct {
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type taskPlanState struct {
|
||||
Items []taskPlanItem `json:"items,omitempty"`
|
||||
Pending int `json:"pending"`
|
||||
InProgress int `json:"in_progress"`
|
||||
Completed int `json:"completed"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type taskToolState struct {
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type taskVerificationState struct {
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
LastOutcome Outcome `json:"last_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type taskStateSnapshot struct {
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
}
|
||||
|
||||
type taskStateEventKind int
|
||||
|
||||
const (
|
||||
taskStateEventPlan taskStateEventKind = iota + 1
|
||||
taskStateEventToolResult
|
||||
taskStateEventVerification
|
||||
taskStateEventCompletion
|
||||
)
|
||||
|
||||
type taskStateEvent struct {
|
||||
kind taskStateEventKind
|
||||
arguments string
|
||||
toolResult ToolResult
|
||||
verification Outcome
|
||||
completion completionEvaluation
|
||||
}
|
||||
|
||||
// taskState is a deterministic projection of facts the loop already observes.
|
||||
// It does not initiate work or replace the transcript; consumers must check
|
||||
// transcript parity before using its compact representation.
|
||||
type taskState struct {
|
||||
snapshotValue taskStateSnapshot
|
||||
changedFiles map[string]struct{}
|
||||
recorder *trace.Recorder
|
||||
planObserved bool
|
||||
}
|
||||
|
||||
func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||
state := &taskState{
|
||||
snapshotValue: taskStateSnapshot{
|
||||
Objective: strings.TrimSpace(objective),
|
||||
Status: taskStatusActive,
|
||||
PlanParity: taskPlanParityUnknown,
|
||||
},
|
||||
changedFiles: map[string]struct{}{},
|
||||
}
|
||||
state.recorder = recorder
|
||||
return state
|
||||
}
|
||||
|
||||
func (state *taskState) observe(event taskStateEvent) bool {
|
||||
if state == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
switch event.kind {
|
||||
case taskStateEventPlan:
|
||||
plan, ok := parseTaskPlan(event.arguments)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
state.snapshotValue.Plan = summarizeTaskPlan(plan)
|
||||
state.planObserved = true
|
||||
state.markActive()
|
||||
changed = true
|
||||
case taskStateEventToolResult:
|
||||
state.markActive()
|
||||
if event.toolResult.Status == tools.StatusOK {
|
||||
state.snapshotValue.Tools.Succeeded++
|
||||
} else {
|
||||
state.snapshotValue.Tools.Failed++
|
||||
}
|
||||
for _, path := range event.toolResult.ChangedFiles {
|
||||
path = strings.TrimSpace(path)
|
||||
if path != "" {
|
||||
state.changedFiles[path] = struct{}{}
|
||||
}
|
||||
}
|
||||
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
||||
changed = true
|
||||
case taskStateEventVerification:
|
||||
if event.verification == OutcomeDisabled {
|
||||
return false
|
||||
}
|
||||
state.markActive()
|
||||
state.snapshotValue.Verification.LastOutcome = event.verification
|
||||
switch event.verification {
|
||||
case OutcomePassed:
|
||||
state.snapshotValue.Verification.Passed++
|
||||
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
|
||||
state.snapshotValue.Verification.Failed++
|
||||
}
|
||||
changed = true
|
||||
case taskStateEventCompletion:
|
||||
state.snapshotValue.CompletionDecision = event.completion.Decision
|
||||
state.snapshotValue.CompletionReason = event.completion.Reason
|
||||
switch event.completion.Decision {
|
||||
case CompletionComplete:
|
||||
state.snapshotValue.Status = taskStatusComplete
|
||||
case CompletionIncomplete:
|
||||
state.snapshotValue.Status = taskStatusIncomplete
|
||||
default:
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
state.snapshotValue.Revision++
|
||||
if event.kind != taskStateEventToolResult {
|
||||
state.emit()
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (state *taskState) markActive() {
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
state.snapshotValue.CompletionDecision = ""
|
||||
state.snapshotValue.CompletionReason = ""
|
||||
}
|
||||
|
||||
func (state *taskState) snapshot() taskStateSnapshot {
|
||||
if state == nil {
|
||||
return taskStateSnapshot{}
|
||||
}
|
||||
snapshot := state.snapshotValue
|
||||
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
|
||||
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// observePlanParity compares only the latest plan projection with the plan tool
|
||||
// calls still present in messages. It mutates the snapshot and emits when the
|
||||
// parity value changes; objective, tool, and verification fields are not part of
|
||||
// this comparison.
|
||||
func (state *taskState) observePlanParity(messages []zeroruntime.Message) taskPlanParity {
|
||||
if state == nil {
|
||||
return taskPlanParityUnknown
|
||||
}
|
||||
transcriptPlan, found, valid := latestTaskPlan(messages)
|
||||
tracked := state.snapshotValue.Plan.Items
|
||||
parity := taskPlanParityMatch
|
||||
if !valid || found != state.planObserved || (found && !reflect.DeepEqual(transcriptPlan, tracked)) {
|
||||
parity = taskPlanParityMismatch
|
||||
}
|
||||
if state.snapshotValue.PlanParity != parity {
|
||||
state.snapshotValue.PlanParity = parity
|
||||
state.snapshotValue.Revision++
|
||||
state.emit()
|
||||
}
|
||||
return parity
|
||||
}
|
||||
|
||||
type completionContext struct {
|
||||
Objective string
|
||||
PlanPending bool
|
||||
PlanMatchesTranscript bool
|
||||
}
|
||||
|
||||
func (state *taskState) completionContext(messages []zeroruntime.Message, transcriptPlanPending bool) completionContext {
|
||||
context := completionContext{PlanPending: transcriptPlanPending}
|
||||
if state == nil {
|
||||
return context
|
||||
}
|
||||
context.Objective = state.snapshotValue.Objective
|
||||
context.PlanMatchesTranscript = state.observePlanParity(messages) == taskPlanParityMatch
|
||||
if context.PlanMatchesTranscript {
|
||||
context.PlanPending = state.snapshotValue.Plan.Pending+state.snapshotValue.Plan.InProgress > 0
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func (state *taskState) snapshotForCompaction(messages []zeroruntime.Message) *taskStateSnapshot {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
state.observePlanParity(messages)
|
||||
snapshot := state.snapshot()
|
||||
return &snapshot
|
||||
}
|
||||
|
||||
func (state *taskState) emit() {
|
||||
if state == nil || state.recorder == nil {
|
||||
return
|
||||
}
|
||||
snapshot := state.snapshotValue
|
||||
state.recorder.EmitTaskState(trace.TaskStateEvent{
|
||||
Revision: snapshot.Revision,
|
||||
Status: string(snapshot.Status),
|
||||
PlanPending: snapshot.Plan.Pending,
|
||||
PlanInProgress: snapshot.Plan.InProgress,
|
||||
PlanCompleted: snapshot.Plan.Completed,
|
||||
PlanFailed: snapshot.Plan.Failed,
|
||||
ToolsSucceeded: snapshot.Tools.Succeeded,
|
||||
ToolsFailed: snapshot.Tools.Failed,
|
||||
VerificationPassed: snapshot.Verification.Passed,
|
||||
VerificationFailed: snapshot.Verification.Failed,
|
||||
VerificationOutcome: string(snapshot.Verification.LastOutcome),
|
||||
ChangedFileCount: len(snapshot.ChangedFiles),
|
||||
CompletionDecision: string(snapshot.CompletionDecision),
|
||||
PlanParity: string(snapshot.PlanParity),
|
||||
})
|
||||
}
|
||||
|
||||
func parseTaskPlan(arguments string) ([]taskPlanItem, bool) {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
||||
return nil, false
|
||||
}
|
||||
rawPlan, ok := object["plan"]
|
||||
if !ok || string(rawPlan) == "null" {
|
||||
return nil, false
|
||||
}
|
||||
var parsed []struct {
|
||||
ID *string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if json.Unmarshal(rawPlan, &parsed) != nil {
|
||||
return nil, false
|
||||
}
|
||||
plan := make([]taskPlanItem, 0, len(parsed))
|
||||
for _, raw := range parsed {
|
||||
if raw.Content == "" {
|
||||
return nil, false
|
||||
}
|
||||
plan = append(plan, taskPlanItem{
|
||||
Content: raw.Content,
|
||||
Status: normalizeTaskPlanStatus(raw.Status),
|
||||
Notes: raw.Notes,
|
||||
})
|
||||
}
|
||||
lastInProgress := -1
|
||||
for index := range plan {
|
||||
if plan[index].Status == "in_progress" {
|
||||
if lastInProgress >= 0 {
|
||||
plan[lastInProgress].Status = "completed"
|
||||
}
|
||||
lastInProgress = index
|
||||
}
|
||||
}
|
||||
if len(plan) == 0 {
|
||||
return nil, true
|
||||
}
|
||||
return plan, true
|
||||
}
|
||||
|
||||
func normalizeTaskPlanStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]":
|
||||
return "completed"
|
||||
case "in_progress", "in-progress", "inprogress", "in progress", "active", "doing", "started", "current", "wip", "ongoing", "running":
|
||||
return "in_progress"
|
||||
case "failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
return "failed"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeTaskPlan(items []taskPlanItem) taskPlanState {
|
||||
plan := taskPlanState{Items: append([]taskPlanItem(nil), items...)}
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case "completed":
|
||||
plan.Completed++
|
||||
case "in_progress":
|
||||
plan.InProgress++
|
||||
case "failed":
|
||||
plan.Failed++
|
||||
default:
|
||||
plan.Pending++
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func latestTaskPlan(messages []zeroruntime.Message) ([]taskPlanItem, bool, bool) {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
for j := len(messages[i].ToolCalls) - 1; j >= 0; j-- {
|
||||
call := messages[i].ToolCalls[j]
|
||||
if call.Name != planToolName {
|
||||
continue
|
||||
}
|
||||
plan, ok := parseTaskPlan(call.Arguments)
|
||||
return plan, true, ok
|
||||
}
|
||||
}
|
||||
return nil, false, true
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
keys = append(keys, value)
|
||||
}
|
||||
// Paths are evidence, not chronology. Stable ordering makes replay snapshots
|
||||
// deterministic even if events originate from parallel read batches later.
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestTaskStateReplayIsDeterministic(t *testing.T) {
|
||||
events := []taskStateEvent{
|
||||
{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"inspect","status":"done"},{"content":"implement","status":"in progress"},{"content":"verify"}]}`},
|
||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "apply_patch", Status: tools.StatusOK, ChangedFiles: []string{"a.go", "a.go", "b.go"}}},
|
||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "go test", Status: tools.StatusError}},
|
||||
{kind: taskStateEventVerification, verification: OutcomePassed},
|
||||
{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionUncertain, Reason: "pending work"}},
|
||||
}
|
||||
|
||||
first := newTaskState("ship the change", nil)
|
||||
second := newTaskState("ship the change", nil)
|
||||
for _, event := range events {
|
||||
first.observe(event)
|
||||
second.observe(event)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(first.snapshot(), second.snapshot()) {
|
||||
t.Fatalf("replaying the same events produced different snapshots:\nfirst: %#v\nsecond: %#v", first.snapshot(), second.snapshot())
|
||||
}
|
||||
got := first.snapshot()
|
||||
if got.Status != taskStatusActive || got.Plan.Pending != 1 || got.Plan.InProgress != 1 || got.Plan.Completed != 1 {
|
||||
t.Fatalf("unexpected plan snapshot: %#v", got)
|
||||
}
|
||||
if got.Tools.Succeeded != 1 || got.Tools.Failed != 1 || len(got.ChangedFiles) != 2 {
|
||||
t.Fatalf("unexpected evidence snapshot: %#v", got)
|
||||
}
|
||||
if got.Verification.Passed != 1 || got.Verification.Failed != 0 || got.Verification.LastOutcome != OutcomePassed {
|
||||
t.Fatalf("unexpected verification snapshot: %#v", got.Verification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateCoalescesToolResultsIntoNextTraceEvent(t *testing.T) {
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
state := newTaskState("objective", recorder)
|
||||
for range 20 {
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||
}
|
||||
if events := recorder.Finish().TaskStates; len(events) != 0 {
|
||||
t.Fatalf("tool results should be coalesced, got %d trace events", len(events))
|
||||
}
|
||||
|
||||
recorder = trace.NewRecorder("session", "run-2", "")
|
||||
state = newTaskState("objective", recorder)
|
||||
for range 20 {
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) != 1 || events[0].ToolsSucceeded != 20 {
|
||||
t.Fatalf("coalesced tool total missing from completion snapshot: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEmitsTaskStateFromExistingLoopEvents(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "plan-1", ToolName: planToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "plan-1", ArgumentsFragment: `{"plan":[{"content":"implement","status":"completed"}]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "plan-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
|
||||
result, err := Run(context.Background(), "implement the change", provider, Options{
|
||||
Registry: registry,
|
||||
Trace: recorder,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) < 3 {
|
||||
t.Fatalf("expected plan, tool, completion, and parity snapshots, got %#v", events)
|
||||
}
|
||||
last := events[len(events)-1]
|
||||
if last.Status != string(taskStatusComplete) || last.PlanCompleted != 1 || last.ToolsSucceeded != 1 || last.PlanParity != string(taskPlanParityMatch) {
|
||||
t.Fatalf("unexpected final task snapshot: %#v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"one","status":"pending"}]}`})
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK, ChangedFiles: []string{"one.go"}}})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
snapshot.Plan.Items[0].Content = "mutated"
|
||||
snapshot.ChangedFiles[0] = "mutated.go"
|
||||
|
||||
fresh := state.snapshot()
|
||||
if fresh.Plan.Items[0].Content != "one" || fresh.ChangedFiles[0] != "one.go" {
|
||||
t.Fatalf("snapshot mutation leaked into task state: %#v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`})
|
||||
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`},
|
||||
}}}
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("parity = %q, want match", parity)
|
||||
}
|
||||
|
||||
messages[0].ToolCalls[0].Arguments = `{"plan":[{"content":"different","status":"pending"}]}`
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMismatch {
|
||||
t.Fatalf("parity = %q, want mismatch", parity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateMatchesPlanToolNormalization(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
arguments := `{"plan":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: arguments})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
if snapshot.Plan.Completed != 1 || snapshot.Plan.InProgress != 1 {
|
||||
t.Fatalf("multiple active items were not normalized like the plan tool: %#v", snapshot.Plan)
|
||||
}
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: arguments}}}}
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("normalized state should still match its transcript event, got %q", parity)
|
||||
}
|
||||
|
||||
empty := newTaskState("objective", nil)
|
||||
empty.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[]}`})
|
||||
emptyMessages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[]}`}}}}
|
||||
if parity := empty.observePlanParity(emptyMessages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("explicit empty plan should match transcript, got %q", parity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateContextFallsBackWhenTranscriptDiffers(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"tracked","status":"completed"}]}`})
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"transcript","status":"pending"}]}`},
|
||||
}}}
|
||||
|
||||
context := state.completionContext(messages, true)
|
||||
if !context.PlanPending || context.PlanMatchesTranscript {
|
||||
t.Fatalf("completion context must retain transcript truth on mismatch: %#v", context)
|
||||
}
|
||||
if context.Objective != "objective" {
|
||||
t.Fatalf("objective lost from completion context: %#v", context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateCompactionSnapshotRetainsObjectiveOnPlanMismatch(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"verify","status":"pending"}]}`})
|
||||
matching := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"verify","status":"pending"}]}`},
|
||||
}}}
|
||||
if snapshot := state.snapshotForCompaction(matching); snapshot == nil || snapshot.Objective != "objective" {
|
||||
t.Fatalf("matching transcript should produce compact state, got %#v", snapshot)
|
||||
}
|
||||
|
||||
mismatching := append([]zeroruntime.Message(nil), matching...)
|
||||
mismatching[0].ToolCalls = []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[{"content":"other","status":"pending"}]}`}}
|
||||
if snapshot := state.snapshotForCompaction(mismatching); snapshot == nil || snapshot.Objective != "objective" || snapshot.PlanParity != taskPlanParityMismatch {
|
||||
t.Fatalf("plan mismatch must retain immutable objective and mark mutable state uncorroborated, got %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTaskPlanRejectsArgumentsTheToolWouldReject(t *testing.T) {
|
||||
for _, arguments := range []string{
|
||||
`{}`,
|
||||
`{"plan":null}`,
|
||||
`{"plan":[{"step":"alias is not accepted"}]}`,
|
||||
`{"plan":[{"content":""}]}`,
|
||||
`{"plan":[{"content":"valid"},{"content":""}]}`,
|
||||
`{"plan":[{"id":4,"content":"valid"}]}`,
|
||||
} {
|
||||
if plan, ok := parseTaskPlan(arguments); ok {
|
||||
t.Fatalf("parseTaskPlan(%s) = %#v, true; want rejected", arguments, plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,12 +939,6 @@ func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
// An explicit `"sandbox": {"enabled": false}` disables the sandbox: every
|
||||
// request is allowed and commands run unwrapped. Only reachable from global
|
||||
// config / CLI — project config never sets Enabled (see mergeProjectConfig).
|
||||
if cfg.Enabled != nil && !*cfg.Enabled {
|
||||
policy.Mode = sandbox.ModeDisabled
|
||||
}
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
|
||||
@@ -72,11 +72,8 @@ func preflightExecSession(options execOptions) error {
|
||||
if session == nil {
|
||||
return execUsageError{"Zero session not found: " + options.resume}
|
||||
}
|
||||
if !sessions.IsResumableKind(session.SessionKind) {
|
||||
return execUsageError{"Zero session is not resumable: " + options.resume}
|
||||
}
|
||||
case options.resumeLatest:
|
||||
latest, err := store.LatestResumable()
|
||||
latest, err := store.Latest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func TestPreflightRejectsSideSessions(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
store := sessions.NewStore(sessions.StoreOptions{})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide}); err != nil {
|
||||
t.Fatalf("create side session: %v", err)
|
||||
}
|
||||
|
||||
err := preflightExecSession(execOptions{resumeLatest: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "No Zero sessions available to resume") {
|
||||
t.Fatalf("preflightExecSession error = %v, want no resumable sessions", err)
|
||||
}
|
||||
|
||||
err = preflightExecSession(execOptions{resume: "side"})
|
||||
if err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: side") {
|
||||
t.Fatalf("explicit side-session preflight error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -53,22 +53,6 @@ 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)
|
||||
@@ -368,22 +352,6 @@ 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)
|
||||
@@ -460,16 +428,6 @@ 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)
|
||||
@@ -489,120 +447,3 @@ 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,111 +75,6 @@ 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
|
||||
|
||||
@@ -1001,18 +1001,3 @@ func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) {
|
||||
disabled := false
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled {
|
||||
t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode)
|
||||
}
|
||||
// Omitted (nil) and explicit true keep the default enforcing mode.
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled {
|
||||
t.Fatal("omitted enabled must not disable the sandbox")
|
||||
}
|
||||
enabled := true
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled {
|
||||
t.Fatal("enabled:true must not disable the sandbox")
|
||||
}
|
||||
}
|
||||
@@ -216,10 +216,10 @@ func parseNonEmptySessionsFlag(flag string, value string) (string, error) {
|
||||
func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
|
||||
kind := sessions.SessionKind(strings.ToLower(strings.TrimSpace(value)))
|
||||
switch kind {
|
||||
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSide, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
|
||||
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
|
||||
return kind, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, side, spec-draft, or spec-impl.", value)}
|
||||
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, spec-draft, or spec-impl.", value)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ Commands:
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
--kind <kind> Filter list by fork, child, side, spec-draft, or spec-impl
|
||||
--kind <kind> Filter list by fork, child, spec-draft, or spec-impl
|
||||
--sequence <n> Rewind target sequence (rewind-plan, rewind)
|
||||
--event <id> Rewind target event id (rewind-plan, rewind)
|
||||
--exclude-target Drop the target event (rewind-plan, rewind)
|
||||
|
||||
@@ -233,9 +233,6 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide, Title: "BTW side"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -252,17 +249,6 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
|
||||
t.Fatalf("filtered sessions output = %q", output)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sessions", "list", "--kind", "side"}, &stdout, &stderr, appDeps{
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess || !strings.Contains(stdout.String(), "side") || strings.Contains(stdout.String(), "regular") {
|
||||
t.Fatalf("sessions list --kind side exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sessions", "tree", "draft", "--kind", "spec-draft"}, &stdout, &stderr, appDeps{
|
||||
|
||||
@@ -93,16 +93,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
}
|
||||
// Sandbox.Enabled is NOT accepted from a provider command, for the same
|
||||
// reason project config cannot set it (see mergeProjectConfig): only
|
||||
// global config and the CLI may turn the sandbox off. A provider command
|
||||
// is an arbitrary executable named in config, and LoadProviderCommand
|
||||
// parses its stdout into a full FileConfig — so without this, a command
|
||||
// returning a valid provider plus {"sandbox":{"enabled":false}} would
|
||||
// disable the very sandbox meant to constrain what it can do. Cleared
|
||||
// here rather than in mergeConfig because that helper is shared with the
|
||||
// trusted global-config merge, which must keep honouring the setting.
|
||||
commandConfig.Sandbox.Enabled = nil
|
||||
mergeConfig(&cfg, commandConfig)
|
||||
}
|
||||
|
||||
@@ -226,9 +216,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP, true)
|
||||
if src.Sandbox.Enabled != nil {
|
||||
dst.Sandbox.Enabled = src.Sandbox.Enabled
|
||||
}
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
@@ -286,10 +273,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
|
||||
return err
|
||||
}
|
||||
// Sandbox.Enabled is intentionally NOT merged from project config: a cloned
|
||||
// repo's .zero/config.json must not be able to disable the sandbox that
|
||||
// constrains it. Only global config and CLI can turn the sandbox off.
|
||||
//
|
||||
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
@@ -705,9 +688,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if overrides.MaxTurns > 0 {
|
||||
cfg.MaxTurns = overrides.MaxTurns
|
||||
}
|
||||
if overrides.Sandbox.Enabled != nil {
|
||||
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
|
||||
}
|
||||
if overrides.Sandbox.BlockUnixSockets {
|
||||
cfg.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
|
||||
@@ -1744,85 +1744,6 @@ func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxEnabledUserConfigDisables(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
|
||||
t.Fatalf("user config enabled:false must resolve to a false pointer, got %v", resolved.Sandbox.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) {
|
||||
// Security: a cloned repo's project config must NOT be able to disable the
|
||||
// sandbox that constrains it. Only global config / CLI can turn it off.
|
||||
userPath := writeConfig(t, `{}`)
|
||||
projectPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Enabled != nil {
|
||||
t.Fatalf("project config enabled:false must be ignored, got %v — a repo could disable the sandbox", *resolved.Sandbox.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxEnabledIgnoredFromProviderCommand(t *testing.T) {
|
||||
// Security: a provider command is an arbitrary executable named in config, and
|
||||
// its stdout is parsed into a full FileConfig — so it must NOT be able to reach
|
||||
// Sandbox.Enabled, in either direction. Same rule as project config: only global
|
||||
// config / CLI may turn the sandbox off.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "cannot disable", value: "false"},
|
||||
{name: "cannot enable", value: "true"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"sandbox":{"enabled":` + tc.value + `}}`,
|
||||
})
|
||||
resolved, err := Resolve(ResolveOptions{ProviderCommand: command, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
// The provider itself must still be applied — only the sandbox toggle is dropped.
|
||||
if resolved.ActiveProvider != "cmd" {
|
||||
t.Fatalf("ActiveProvider = %q, want cmd (provider command must still apply)", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.Sandbox.Enabled != nil {
|
||||
t.Fatalf("provider command set sandbox.enabled=%s, got %v — a provider command could toggle the sandbox", tc.value, *resolved.Sandbox.Enabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxEnabledCLIOverride(t *testing.T) {
|
||||
// A CLI/programmatic override (applied after every config source) must be able
|
||||
// to disable the sandbox, even with no config file.
|
||||
disabled := false
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &disabled}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
|
||||
t.Fatalf("CLI override enabled:false must disable the sandbox, got %v", resolved.Sandbox.Enabled)
|
||||
}
|
||||
// An explicit true override must win over a lower-precedence global-config false.
|
||||
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
|
||||
enabled := true
|
||||
resolved2, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &enabled}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved2.Sandbox.Enabled == nil || !*resolved2.Sandbox.Enabled {
|
||||
t.Fatalf("CLI override enabled:true must win over config false, got %v", resolved2.Sandbox.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyValid(t *testing.T) {
|
||||
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
|
||||
@@ -62,12 +62,6 @@ func HasProviderProfile(profile ProviderProfile) bool {
|
||||
}
|
||||
|
||||
type SandboxConfig struct {
|
||||
// Enabled turns the sandbox off when set to false (`"sandbox": {"enabled":
|
||||
// false}`). A pointer distinguishes an explicit false (disable) from an
|
||||
// omitted key (keep the default: enabled). Honored from the GLOBAL user
|
||||
// config and CLI only — deliberately NOT project config, so a cloned repo
|
||||
// cannot disable the sandbox that constrains it. nil / true keep enforcement.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// Network controls whether shell commands classified as network-touching
|
||||
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
|
||||
// Empty keeps the built-in default (deny). Without this knob the engine's
|
||||
|
||||
@@ -177,33 +177,6 @@ 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
|
||||
}
|
||||
// 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 client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
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
|
||||
}
|
||||
// Refuse redirects so a poll redirect can't replay the device_code/secret
|
||||
// POST body to an unvalidated origin.
|
||||
client = withoutRedirects(client)
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", deviceGrantType)
|
||||
form.Set("device_code", deviceCode)
|
||||
|
||||
@@ -2,7 +2,6 @@ package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -29,30 +28,6 @@ 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}`))
|
||||
|
||||
+3
-20
@@ -116,23 +116,6 @@ 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
|
||||
@@ -193,9 +176,9 @@ func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, f
|
||||
if err := validateTokenEndpoint(tokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
// 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 client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -128,64 +126,6 @@ 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,11 +90,6 @@ 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,54 +578,28 @@ 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 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.
|
||||
// decided from the stream-json run_end exit code (and the task's
|
||||
// VerificationCommand when present), exactly like NewExecRunner.
|
||||
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. 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)}
|
||||
// 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
|
||||
}
|
||||
// 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 {
|
||||
@@ -682,23 +656,10 @@ func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// A nonzero agent exit already decided failure; don't run verification or
|
||||
// mark the task passed.
|
||||
if outcome.VerifyErr != "" {
|
||||
if len(task.VerificationCommand) == 0 || exitCode != execExitIncomplete {
|
||||
return outcome
|
||||
}
|
||||
outcome.VerifyErr = ""
|
||||
return outcome
|
||||
}
|
||||
// Stamp the compiler-backed oracle and capture the agent's final answer
|
||||
// BEFORE running the verification command. Both write into the fixture
|
||||
@@ -829,23 +790,7 @@ func stampOracleAndAnswer(task BenchTask, outBuf []byte) error {
|
||||
}
|
||||
|
||||
func buildTurnExecArgs(task BenchTask, rc RunContext, tracePath string, extraArgs []string) []string {
|
||||
// --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}
|
||||
args := []string{"exec", "--output-format", "stream-json", "--trace", tracePath}
|
||||
if model := strings.TrimSpace(rc.Model); model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -751,92 +750,6 @@ 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
|
||||
@@ -1279,57 +1192,6 @@ 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
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1376,61 +1375,3 @@ func TestOpenAIRequestPromptCacheKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestPreservesCacheablePrefixAcrossTurns(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
tools := []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file",
|
||||
Description: "Read a file.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
firstMessages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "stable system prompt"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "first turn"},
|
||||
}
|
||||
secondMessages := append([]zeroruntime.Message(nil), firstMessages...)
|
||||
secondMessages = append(secondMessages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "first response"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "second turn"},
|
||||
)
|
||||
|
||||
marshalBody := func(messages []zeroruntime.Message) map[string]any {
|
||||
t.Helper()
|
||||
mapped := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
PromptCacheKey: "session-stable-prefix",
|
||||
})
|
||||
data, marshalErr := json.Marshal(mapped)
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal request: %v", marshalErr)
|
||||
}
|
||||
var body map[string]any
|
||||
if unmarshalErr := json.Unmarshal(data, &body); unmarshalErr != nil {
|
||||
t.Fatalf("unmarshal request: %v", unmarshalErr)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
first := marshalBody(firstMessages)
|
||||
second := marshalBody(secondMessages)
|
||||
firstWireMessages := first["messages"].([]any)
|
||||
secondWireMessages := second["messages"].([]any)
|
||||
if !reflect.DeepEqual(secondWireMessages[:len(firstWireMessages)], firstWireMessages) {
|
||||
t.Fatalf("first wire messages must be an exact prefix of the second:\nfirst=%#v\nsecond=%#v", firstWireMessages, secondWireMessages)
|
||||
}
|
||||
if !reflect.DeepEqual(first["tools"], second["tools"]) {
|
||||
t.Fatalf("wire tool definitions drifted:\nfirst=%#v\nsecond=%#v", first["tools"], second["tools"])
|
||||
}
|
||||
if first["prompt_cache_key"] != second["prompt_cache_key"] || first["prompt_cache_key"] != "session-stable-prefix" {
|
||||
t.Fatalf("wire prompt cache key must remain stable: first=%#v second=%#v", first["prompt_cache_key"], second["prompt_cache_key"])
|
||||
}
|
||||
}
|
||||
@@ -93,40 +93,6 @@ 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,15 +232,6 @@ 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
|
||||
|
||||
@@ -89,7 +89,7 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
if resumeID != "" || options.ResumeLatest {
|
||||
sessionID := resumeID
|
||||
if sessionID == "" && options.ResumeLatest {
|
||||
latest, err := store.LatestResumable()
|
||||
latest, err := store.Latest()
|
||||
if err != nil {
|
||||
return PreparedExec{}, err
|
||||
}
|
||||
@@ -105,9 +105,6 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
if session == nil {
|
||||
return PreparedExec{}, ExecError{"Zero session not found: " + sessionID}
|
||||
}
|
||||
if !IsResumableKind(session.SessionKind) {
|
||||
return PreparedExec{}, ExecError{"Zero session is not resumable: " + sessionID}
|
||||
}
|
||||
contextEvents, err := readExecContextEvents(store, session.SessionID)
|
||||
if err != nil {
|
||||
return PreparedExec{}, err
|
||||
|
||||
+10
-20
@@ -52,7 +52,6 @@ type SessionKind string
|
||||
const (
|
||||
SessionKindFork SessionKind = "fork"
|
||||
SessionKindChild SessionKind = "child"
|
||||
SessionKindSide SessionKind = "side"
|
||||
SessionKindSpecDraft SessionKind = "spec-draft"
|
||||
SessionKindSpecImpl SessionKind = "spec-impl"
|
||||
)
|
||||
@@ -126,13 +125,11 @@ type CreateInput struct {
|
||||
}
|
||||
|
||||
type ForkInput struct {
|
||||
SessionID string
|
||||
SessionKind SessionKind
|
||||
Title string
|
||||
Cwd string
|
||||
ModelID string
|
||||
Provider string
|
||||
Tag string
|
||||
SessionID string
|
||||
Title string
|
||||
Cwd string
|
||||
ModelID string
|
||||
Provider string
|
||||
}
|
||||
|
||||
type ChildInput struct {
|
||||
@@ -357,9 +354,10 @@ func (store *Store) Latest() (*Metadata, error) {
|
||||
}
|
||||
|
||||
// IsResumableKind reports whether a session kind represents a standalone,
|
||||
// user-resumable conversation rather than a transient or agent-owned sub-run.
|
||||
// Regular ("") and user fork sessions are resumable; side, child, and spec
|
||||
// sessions are not, so they do not flood the resume picker.
|
||||
// user-resumable conversation rather than an agent sub-run. Regular ("") and
|
||||
// user fork sessions are resumable; child (specialist/sub-agent) and spec
|
||||
// draft/impl sessions are not — each agent task and /spec run creates one, so
|
||||
// listing them in the resume picker floods it with non-conversation entries.
|
||||
func IsResumableKind(kind SessionKind) bool {
|
||||
switch kind {
|
||||
case "", SessionKindFork:
|
||||
@@ -420,21 +418,13 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if title == "" && parent.Title != "" {
|
||||
title = parent.Title + " (fork)"
|
||||
}
|
||||
kind := input.SessionKind
|
||||
if kind == "" {
|
||||
kind = SessionKindFork
|
||||
}
|
||||
if kind != SessionKindFork && kind != SessionKindSide {
|
||||
return Metadata{}, fmt.Errorf("invalid zero fork session kind %q", kind)
|
||||
}
|
||||
fork, err := store.Create(CreateInput{
|
||||
SessionID: input.SessionID,
|
||||
SessionKind: kind,
|
||||
SessionKind: SessionKindFork,
|
||||
Title: title,
|
||||
Cwd: firstNonEmpty(input.Cwd, parent.Cwd),
|
||||
ModelID: firstNonEmpty(input.ModelID, parent.ModelID),
|
||||
Provider: firstNonEmpty(input.Provider, parent.Provider),
|
||||
Tag: input.Tag,
|
||||
ParentSessionID: parent.SessionID,
|
||||
RootSessionID: firstNonEmpty(parent.RootSessionID, parent.SessionID),
|
||||
ForkedFromEventID: last.ID,
|
||||
|
||||
@@ -181,32 +181,6 @@ func TestStoreForkCopiesEventsAndLineage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreForkSupportsNonResumableSideSession(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir()})
|
||||
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create parent: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(parent.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": "context"}}); err != nil {
|
||||
t.Fatalf("AppendEvent: %v", err)
|
||||
}
|
||||
|
||||
side, err := store.Fork(parent.SessionID, ForkInput{
|
||||
SessionID: "side",
|
||||
SessionKind: SessionKindSide,
|
||||
Tag: "btw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork side: %v", err)
|
||||
}
|
||||
if side.SessionKind != SessionKindSide || side.Tag != "btw" || side.ParentSessionID != parent.SessionID {
|
||||
t.Fatalf("side metadata = %#v", side)
|
||||
}
|
||||
if IsResumableKind(side.SessionKind) {
|
||||
t.Fatal("side sessions must not appear as resumable conversations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreForkSkipsUsageEvents(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T11:00:00Z")})
|
||||
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent"})
|
||||
@@ -407,7 +381,6 @@ func TestListAndLatestResumableExcludeSubRuns(t *testing.T) {
|
||||
mk(SessionKindSpecImpl, "spec-impl-1")
|
||||
newestResumable := mk("", "conversation-2") // newest standalone conversation
|
||||
mk(SessionKindChild, "child-2") // newer overall, but a sub-run
|
||||
mk(SessionKindSide, "side-1") // newer overall, but non-resumable
|
||||
|
||||
resumable, err := store.ListResumable()
|
||||
if err != nil {
|
||||
@@ -431,7 +404,7 @@ func TestListAndLatestResumableExcludeSubRuns(t *testing.T) {
|
||||
if latest != nil {
|
||||
got = latest.SessionID
|
||||
}
|
||||
t.Fatalf("LatestResumable = %s, want newest resumable %s (must skip newer child and side sessions)", got, newestResumable.SessionID)
|
||||
t.Fatalf("LatestResumable = %s, want newest resumable %s (must skip the newer child)", got, newestResumable.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,9 +535,6 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
|
||||
if _, err := store.AppendEvent("latest", AppendEventInput{Type: EventMessage, Payload: map[string]any{"content": "previous answer"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Create(CreateInput{SessionID: "newer-side", SessionKind: SessionKindSide}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prepared, err := PrepareExec(PrepareExecOptions{Store: store, ResumeLatest: true})
|
||||
if err != nil {
|
||||
@@ -576,9 +546,6 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
|
||||
if got := FormatExecPrompt("continue", prepared); got == "continue" || !strings.Contains(got, "previous answer") {
|
||||
t.Fatalf("expected session context in prompt, got %q", got)
|
||||
}
|
||||
if _, err := PrepareExec(PrepareExecOptions{Store: store, Resume: "newer-side"}); err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: newer-side") {
|
||||
t.Fatalf("explicit side-session resume error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
|
||||
forked, err := PrepareExec(PrepareExecOptions{Store: store, Fork: "latest", SessionID: "forked"})
|
||||
if err != nil {
|
||||
|
||||
@@ -707,18 +707,6 @@ 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
|
||||
}
|
||||
@@ -734,7 +722,7 @@ func NewWriteStdinTool(manager *execSessionManager) Tool {
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"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)},
|
||||
"session_id": {Type: "integer", Description: "Identifier of the running unified exec session."},
|
||||
"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)},
|
||||
@@ -775,20 +763,12 @@ 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 {
|
||||
// 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))
|
||||
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())
|
||||
}
|
||||
chars, err := stringArgWithEmpty(args, "chars", "", false, true)
|
||||
if err != nil {
|
||||
@@ -804,7 +784,7 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
|
||||
}
|
||||
session, ok := tool.manager.get(sessionID)
|
||||
if !ok {
|
||||
return errorResult(UnknownExecSessionError(sessionID))
|
||||
return errorResult(fmt.Sprintf("Error: Unknown exec session_id %d.", 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, "still-running exec_command") {
|
||||
if poll.Status != StatusError || !strings.Contains(poll.Output, "Unknown exec session_id") {
|
||||
t.Fatalf("empty poll should reach tool without permission prompt, got status=%s output=%q", poll.Status, poll.Output)
|
||||
}
|
||||
|
||||
@@ -901,57 +901,24 @@ func TestWriteStdinReportsUnknownSession(t *testing.T) {
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %s, want error", result.Status)
|
||||
}
|
||||
// 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)
|
||||
if !strings.Contains(result.Output, "Unknown exec session_id 1234") {
|
||||
t.Fatalf("unexpected output: %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())
|
||||
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"},
|
||||
for _, args := range []map[string]any{
|
||||
{},
|
||||
{"session_id": 0},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -114,9 +113,6 @@ func (registry *Registry) All() []Tool {
|
||||
for _, tool := range registry.tools {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
sort.Slice(tools, func(left, right int) bool {
|
||||
return tools[left].Name() < tools[right].Name()
|
||||
})
|
||||
return tools
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -73,23 +72,6 @@ func TestCoreReadOnlyToolsExposeSafeMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAllReturnsToolsSortedByName(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
for _, name := range []string{"write_file", "bash", "read_file"} {
|
||||
registry.Register(fakePlainTool{baseTool: baseTool{name: name, description: name}})
|
||||
}
|
||||
|
||||
all := registry.All()
|
||||
got := make([]string, 0, len(all))
|
||||
for _, tool := range all {
|
||||
got = append(got, tool.Name())
|
||||
}
|
||||
want := []string{"bash", "read_file", "write_file"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("Registry.All() order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreNetworkToolsExposeSafetyMetadata(t *testing.T) {
|
||||
// web_search is only registered when a backend is configured.
|
||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||
|
||||
@@ -106,7 +106,6 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
|
||||
for _, p := range t.PrefixHashes {
|
||||
if err := enc.Encode(map[string]any{
|
||||
"type": "prefix_hash",
|
||||
"system_prompt": p.SystemPromptHash,
|
||||
"base_instructions": p.BaseInstructionsHash,
|
||||
"confirmation_policy": p.ConfirmationPolicyHash,
|
||||
"project_context": p.ProjectContextHash,
|
||||
@@ -137,27 +136,6 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, event := range t.TaskStates {
|
||||
if err := enc.Encode(map[string]any{
|
||||
"type": "task_state",
|
||||
"revision": event.Revision,
|
||||
"status": event.Status,
|
||||
"plan_pending": event.PlanPending,
|
||||
"plan_in_progress": event.PlanInProgress,
|
||||
"plan_completed": event.PlanCompleted,
|
||||
"plan_failed": event.PlanFailed,
|
||||
"tools_succeeded": event.ToolsSucceeded,
|
||||
"tools_failed": event.ToolsFailed,
|
||||
"verification_passed": event.VerificationPassed,
|
||||
"verification_failed": event.VerificationFailed,
|
||||
"verification_outcome": event.VerificationOutcome,
|
||||
"changed_file_count": event.ChangedFileCount,
|
||||
"completion_decision": event.CompletionDecision,
|
||||
"plan_parity": event.PlanParity,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -227,15 +205,6 @@ func WriteText(w io.Writer, t *TurnTrace) error {
|
||||
event.Truncated, event.Reason, event.SpillCreated)
|
||||
}
|
||||
}
|
||||
if len(t.TaskStates) > 0 {
|
||||
latest := t.TaskStates[len(t.TaskStates)-1]
|
||||
write("task state: revision=%d status=%s plan=%d/%d/%d/%d tools=%d/%d verification=%d/%d(%s) files=%d plan_parity=%s completion=%s\n",
|
||||
latest.Revision, latest.Status, latest.PlanPending, latest.PlanInProgress,
|
||||
latest.PlanCompleted, latest.PlanFailed, latest.ToolsSucceeded,
|
||||
latest.ToolsFailed, latest.VerificationPassed, latest.VerificationFailed,
|
||||
latest.VerificationOutcome, latest.ChangedFileCount, latest.PlanParity,
|
||||
latest.CompletionDecision)
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
|
||||
+4
-25
@@ -101,14 +101,13 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: not a valid NDJSON trace (no type:trace header)")
|
||||
}
|
||||
// Round-trip the prefix-hash fields. Missing fields are
|
||||
// Round-trip the seven prefix-hash fields. Missing fields are
|
||||
// accepted as empty strings (a partially-written trace from a
|
||||
// crashed emitter must not fatal a parse). The decoder tolerates
|
||||
// any shape the encoder produced and the keys are the
|
||||
// any shape the encoder produced and the seven keys are the
|
||||
// contract — adding a new field is non-breaking; renaming one
|
||||
// requires a schema version bump.
|
||||
t.PrefixHashes = append(t.PrefixHashes, PrefixHash{
|
||||
SystemPromptHash: stringField(obj, "system_prompt"),
|
||||
BaseInstructionsHash: stringField(obj, "base_instructions"),
|
||||
ConfirmationPolicyHash: stringField(obj, "confirmation_policy"),
|
||||
ProjectContextHash: stringField(obj, "project_context"),
|
||||
@@ -132,26 +131,6 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
Reason: stringField(obj, "reason"),
|
||||
SpillCreated: boolField(obj, "spill_created"),
|
||||
})
|
||||
case "task_state":
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: not a valid NDJSON trace (no type:trace header)")
|
||||
}
|
||||
t.TaskStates = append(t.TaskStates, TaskStateEvent{
|
||||
Revision: int(parseInt64(obj["revision"])),
|
||||
Status: stringField(obj, "status"),
|
||||
PlanPending: int(parseInt64(obj["plan_pending"])),
|
||||
PlanInProgress: int(parseInt64(obj["plan_in_progress"])),
|
||||
PlanCompleted: int(parseInt64(obj["plan_completed"])),
|
||||
PlanFailed: int(parseInt64(obj["plan_failed"])),
|
||||
ToolsSucceeded: int(parseInt64(obj["tools_succeeded"])),
|
||||
ToolsFailed: int(parseInt64(obj["tools_failed"])),
|
||||
VerificationPassed: int(parseInt64(obj["verification_passed"])),
|
||||
VerificationFailed: int(parseInt64(obj["verification_failed"])),
|
||||
VerificationOutcome: stringField(obj, "verification_outcome"),
|
||||
ChangedFileCount: int(parseInt64(obj["changed_file_count"])),
|
||||
CompletionDecision: stringField(obj, "completion_decision"),
|
||||
PlanParity: stringField(obj, "plan_parity"),
|
||||
})
|
||||
default:
|
||||
// Unknown event type: tolerate (forward-compat) but only after a
|
||||
// header has been seen.
|
||||
@@ -172,8 +151,8 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
if !sawTraceHeader {
|
||||
return nil, errors.New("parse trace: non-empty input had no type:trace header")
|
||||
}
|
||||
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 && len(t.OutputBudgets) == 0 && len(t.TaskStates) == 0 {
|
||||
return nil, errors.New("parse trace: header present but no events recovered (corrupt or truncated)")
|
||||
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 && len(t.OutputBudgets) == 0 {
|
||||
return nil, errors.New("parse trace: header present but no spans, counters, or prefix hashes recovered (corrupt or truncated)")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -29,10 +29,6 @@ type Recorder struct {
|
||||
firstActionStamped bool
|
||||
}
|
||||
|
||||
// maxTaskStateEvents bounds trace growth in pathological long runs. Once full,
|
||||
// the latest aggregate replaces the tail so final state is never lost.
|
||||
const maxTaskStateEvents = 128
|
||||
|
||||
// NewRecorder returns a ready recorder. sessionID correlates with the agent
|
||||
// session (Options.SessionID); runID is a per-Run sequence; profile is an
|
||||
// optional label (e.g. "cold", "warm") for benchmark runs.
|
||||
@@ -207,24 +203,6 @@ func (r *Recorder) EmitOutputBudget(event OutputBudgetEvent) {
|
||||
r.tr.OutputBudgets = append(r.tr.OutputBudgets, event)
|
||||
}
|
||||
|
||||
// EmitTaskState records one content-free structured task snapshot. Calls retain
|
||||
// event order so revisions can be replayed alongside the tool and turn stream.
|
||||
func (r *Recorder) EmitTaskState(event TaskStateEvent) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.finished {
|
||||
return
|
||||
}
|
||||
if len(r.tr.TaskStates) < maxTaskStateEvents {
|
||||
r.tr.TaskStates = append(r.tr.TaskStates, event)
|
||||
return
|
||||
}
|
||||
r.tr.TaskStates[len(r.tr.TaskStates)-1] = event
|
||||
}
|
||||
|
||||
// Finish stamps CompletedAt, derives each span's parent (by interval
|
||||
// containment) and exclusive time, and returns a snapshot of the trace. Calling
|
||||
// Finish more than once returns the same snapshot.
|
||||
@@ -245,7 +223,6 @@ func (r *Recorder) Finish() *TurnTrace {
|
||||
snap.Counters = append([]Counter(nil), r.tr.Counters...)
|
||||
snap.PrefixHashes = append([]PrefixHash(nil), r.tr.PrefixHashes...)
|
||||
snap.OutputBudgets = append([]OutputBudgetEvent(nil), r.tr.OutputBudgets...)
|
||||
snap.TaskStates = append([]TaskStateEvent(nil), r.tr.TaskStates...)
|
||||
return &snap
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskStateTraceRoundTripContainsNoTaskContent(t *testing.T) {
|
||||
recorder := NewRecorder("session", "run", "")
|
||||
recorder.Start()
|
||||
event := TaskStateEvent{
|
||||
Revision: 4,
|
||||
Status: "active",
|
||||
PlanPending: 2,
|
||||
PlanCompleted: 1,
|
||||
ToolsSucceeded: 3,
|
||||
ToolsFailed: 1,
|
||||
VerificationPassed: 1,
|
||||
VerificationOutcome: "passed",
|
||||
ChangedFileCount: 2,
|
||||
PlanParity: "match",
|
||||
}
|
||||
recorder.EmitTaskState(event)
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := WriteNDJSON(&output, recorder.Finish()); err != nil {
|
||||
t.Fatalf("WriteNDJSON: %v", err)
|
||||
}
|
||||
if strings.Contains(output.String(), "secret objective") || strings.Contains(output.String(), "private.go") {
|
||||
t.Fatalf("task content leaked into trace: %s", output.String())
|
||||
}
|
||||
if strings.Contains(output.String(), "objective_hash") {
|
||||
t.Fatalf("trace contains a confirmable objective fingerprint: %s", output.String())
|
||||
}
|
||||
parsed, err := ReadNDJSON(strings.NewReader(output.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadNDJSON: %v", err)
|
||||
}
|
||||
if len(parsed.TaskStates) != 1 || parsed.TaskStates[0] != event {
|
||||
t.Fatalf("unexpected round trip: %#v", parsed.TaskStates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateIsDocumentedAsOptionalTraceEvent(t *testing.T) {
|
||||
for _, key := range OptionalEventKeys() {
|
||||
if key == "task_state" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("task_state missing from OptionalEventKeys")
|
||||
}
|
||||
|
||||
func TestTaskStateTraceIsBoundedAndRetainsLatestSnapshot(t *testing.T) {
|
||||
recorder := NewRecorder("session", "run", "")
|
||||
for revision := 1; revision <= maxTaskStateEvents+20; revision++ {
|
||||
recorder.EmitTaskState(TaskStateEvent{Revision: revision})
|
||||
}
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) != maxTaskStateEvents {
|
||||
t.Fatalf("task state events = %d, want cap %d", len(events), maxTaskStateEvents)
|
||||
}
|
||||
if got := events[len(events)-1].Revision; got != maxTaskStateEvents+20 {
|
||||
t.Fatalf("latest revision = %d, want %d", got, maxTaskStateEvents+20)
|
||||
}
|
||||
}
|
||||
+24
-31
@@ -103,27 +103,6 @@ type OutputBudgetEvent struct {
|
||||
SpillCreated bool `json:"spill_created"`
|
||||
}
|
||||
|
||||
// TaskStateEvent is a content-free snapshot of the run's structured task
|
||||
// projection. Objective text, plan content, tool output, file paths, and
|
||||
// confirmable content fingerprints are deliberately excluded so tracing cannot
|
||||
// become a secret-bearing side channel.
|
||||
type TaskStateEvent struct {
|
||||
Revision int `json:"revision"`
|
||||
Status string `json:"status"`
|
||||
PlanPending int `json:"plan_pending"`
|
||||
PlanInProgress int `json:"plan_in_progress"`
|
||||
PlanCompleted int `json:"plan_completed"`
|
||||
PlanFailed int `json:"plan_failed"`
|
||||
ToolsSucceeded int `json:"tools_succeeded"`
|
||||
ToolsFailed int `json:"tools_failed"`
|
||||
VerificationPassed int `json:"verification_passed"`
|
||||
VerificationFailed int `json:"verification_failed"`
|
||||
VerificationOutcome string `json:"verification_outcome,omitempty"`
|
||||
ChangedFileCount int `json:"changed_file_count"`
|
||||
CompletionDecision string `json:"completion_decision,omitempty"`
|
||||
PlanParity string `json:"plan_parity"`
|
||||
}
|
||||
|
||||
// TurnTrace is the finished record for one agent.Run. It is the value
|
||||
// emitters serialize; it is not mutated after Finish returns a snapshot.
|
||||
type TurnTrace struct {
|
||||
@@ -139,19 +118,34 @@ type TurnTrace struct {
|
||||
Counters []Counter `json:"counters"`
|
||||
PrefixHashes []PrefixHash `json:"prefix_hashes,omitempty"`
|
||||
OutputBudgets []OutputBudgetEvent `json:"output_budgets,omitempty"`
|
||||
TaskStates []TaskStateEvent `json:"task_states,omitempty"`
|
||||
}
|
||||
|
||||
// PrefixHash is one fingerprint of the prompt prefix emitted by an agent run.
|
||||
// SystemPromptHash covers the exact joined system-message content, including
|
||||
// every dynamic section. ToolsHash covers tool names and descriptions in their
|
||||
// emitted order; SchemaHash covers their canonical parameter schemas in that
|
||||
// same order. CompletePrefixHash combines those three provider-visible parts.
|
||||
// The remaining hashes retain narrower diagnostic boundaries so a trace can
|
||||
// identify common sources of system-prompt drift. All hashes are hex-encoded
|
||||
// SHA-256 and emitted as a "prefix_hash" NDJSON event.
|
||||
// The seven fields decompose the cacheable part of the request so a downstream
|
||||
// observer can detect which sub-component drifted turn-over-turn:
|
||||
// base_instructions (the embedded core system prompt), confirmation_policy,
|
||||
// project_context (AGENTS.md / ZERO.md chain), skills, tools, schema (tool
|
||||
// JSON schemas), and complete_prefix (SHA-256 of the canonical concatenation
|
||||
// of the other six). All hashes are hex-encoded SHA-256.
|
||||
//
|
||||
// Scope: the fingerprint covers 4 of 11 sections of buildSystemPrompt. The
|
||||
// seven sections NOT covered — modelPromptAddendum, sessionRuntimeContext,
|
||||
// approvedCommandPrefixContext, workspaceSeedContext, userGuidelines,
|
||||
// specialistDelegationContext, responseStyleContext — fire only for
|
||||
// non-default Options. For default Options (the common case), the four
|
||||
// captured substrings are the full prompt and the fingerprint is accurate.
|
||||
//
|
||||
// A run with a stable CompletePrefix across turns means the four captured
|
||||
// sub-components are byte-identical. A run where CompletePrefix changes
|
||||
// names the captured sub-component that drifted, but does NOT rule out
|
||||
// drift in the seven uncaptured sections. modelPromptAddendum in particular
|
||||
// changes on a model switch (a model_switches counter the trace already
|
||||
// emits), so a consumer correlating CompletePrefix stability with
|
||||
// cached_input_tokens must cross-check the model_switches counter to
|
||||
// disambiguate "no drift" from "drift in an uncaptured section." The fields
|
||||
// are emitted as a "prefix_hash" event in the NDJSON trace (see
|
||||
// WriteNDJSON).
|
||||
type PrefixHash struct {
|
||||
SystemPromptHash string `json:"system_prompt"`
|
||||
BaseInstructionsHash string `json:"base_instructions"`
|
||||
ConfirmationPolicyHash string `json:"confirmation_policy"`
|
||||
ProjectContextHash string `json:"project_context"`
|
||||
@@ -302,6 +296,5 @@ func OptionalEventKeys() []string {
|
||||
"counter:" + CounterPrefixDrift,
|
||||
"counter:" + CounterPostureEscalations,
|
||||
"event:prefix_hash",
|
||||
"task_state",
|
||||
}
|
||||
}
|
||||
@@ -374,7 +374,6 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
// event type. Insertion order is preserved by the parser (no sort
|
||||
// is applied on read or write).
|
||||
r.EmitPrefixHash(PrefixHash{
|
||||
SystemPromptHash: "system1",
|
||||
BaseInstructionsHash: "b1",
|
||||
ConfirmationPolicyHash: "c1",
|
||||
ProjectContextHash: "p1",
|
||||
@@ -384,7 +383,6 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
CompletePrefixHash: "complete1",
|
||||
})
|
||||
r.EmitPrefixHash(PrefixHash{
|
||||
SystemPromptHash: "system2",
|
||||
BaseInstructionsHash: "b2",
|
||||
ConfirmationPolicyHash: "c2",
|
||||
ProjectContextHash: "p2",
|
||||
@@ -429,10 +427,10 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
if parsed.PrefixHashes[0].CompletePrefixHash != "complete1" || parsed.PrefixHashes[1].CompletePrefixHash != "complete2" {
|
||||
t.Fatalf("prefix_hash insertion order lost: got %+v want [complete1, complete2]", parsed.PrefixHashes)
|
||||
}
|
||||
if parsed.PrefixHashes[0].SystemPromptHash != "system1" || parsed.PrefixHashes[0].BaseInstructionsHash != "b1" || parsed.PrefixHashes[0].SchemaHash != "x1" {
|
||||
if parsed.PrefixHashes[0].BaseInstructionsHash != "b1" || parsed.PrefixHashes[0].SchemaHash != "x1" {
|
||||
t.Fatalf("prefix_hash sub-hashes lost on first event: got %+v", parsed.PrefixHashes[0])
|
||||
}
|
||||
if parsed.PrefixHashes[1].SystemPromptHash != "system2" || parsed.PrefixHashes[1].BaseInstructionsHash != "b2" || parsed.PrefixHashes[1].SchemaHash != "x2" {
|
||||
if parsed.PrefixHashes[1].BaseInstructionsHash != "b2" || parsed.PrefixHashes[1].SchemaHash != "x2" {
|
||||
t.Fatalf("prefix_hash sub-hashes lost on second event: got %+v", parsed.PrefixHashes[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
)
|
||||
|
||||
const (
|
||||
btwSessionTag = "btw"
|
||||
// btwRunIDGap keeps the hidden parent's current run below the first side run.
|
||||
// A parent cannot start another user turn while its surface is hidden; the
|
||||
// monotonic btwRunIDSeq separately prevents collisions across BTW re-entry.
|
||||
btwRunIDGap = 1_000_000
|
||||
|
||||
btwContextBoundary = `You are in an isolated side conversation. The inherited session history is reference context only.
|
||||
Do not continue or complete earlier tasks, plans, tool calls, approvals, or requests. Only instructions submitted after this boundary are active.
|
||||
Answer questions and explore lightly. Do not modify workspace state unless the user explicitly asks for that mutation in this side conversation.
|
||||
Nothing from this side conversation will be merged into the main session.`
|
||||
)
|
||||
|
||||
// btwState keeps the main surface alive while an isolated side conversation is
|
||||
// visible. The parent model continues receiving its own run messages, but its
|
||||
// transcript and session events stay hidden until the user returns.
|
||||
type btwState struct {
|
||||
active bool
|
||||
parent *model
|
||||
sideRunIDBase int
|
||||
parentNeedsInput bool
|
||||
}
|
||||
|
||||
func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
|
||||
question = strings.TrimSpace(question)
|
||||
if m.btw.active {
|
||||
if question != "" {
|
||||
return m.appendSystemNotice("A BTW conversation is already active. Run /btw with no question to return to the main session."), nil
|
||||
}
|
||||
return m.leaveBTW()
|
||||
}
|
||||
if m.compactInFlight {
|
||||
return m.appendSystemNotice("Compaction is running. Wait for it to finish before opening a BTW conversation."), nil
|
||||
}
|
||||
if m.exiting {
|
||||
return m.appendSystemNotice("Zero is waiting for the current run to finish saving before exit. A BTW conversation cannot start now."), nil
|
||||
}
|
||||
if m.activeSession.SessionID == "" {
|
||||
return m.appendSystemNotice("Start the main session with a prompt before opening a BTW conversation."), nil
|
||||
}
|
||||
if m.sessionStore == nil {
|
||||
return m.appendSystemNotice("BTW conversation unavailable: no session store configured."), nil
|
||||
}
|
||||
|
||||
fork, err := m.sessionStore.Fork(m.activeSession.SessionID, sessions.ForkInput{
|
||||
SessionKind: sessions.SessionKindSide,
|
||||
Title: btwTitle(m.activeSession.Title),
|
||||
Cwd: m.cwd,
|
||||
ModelID: m.modelName,
|
||||
Provider: m.providerName,
|
||||
Tag: btwSessionTag,
|
||||
})
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("Could not open BTW conversation: " + err.Error()), nil
|
||||
}
|
||||
events, err := m.resumeEvents(fork.SessionID)
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("Could not load BTW conversation: " + err.Error()), nil
|
||||
}
|
||||
|
||||
// Foreground loops are session-scoped. Stop them only after the side session
|
||||
// is ready, so a failed fork cannot destroy work in the main session.
|
||||
parent := m
|
||||
clearedLoops := 0
|
||||
if updated, cleared := parent.clearLoopsForSessionSwitch(); cleared > 0 {
|
||||
parent = updated
|
||||
clearedLoops = cleared
|
||||
parent.transcript = reduceTranscript(parent.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: fmt.Sprintf("Stopped %d loop(s) before opening the BTW conversation.", cleared),
|
||||
})
|
||||
}
|
||||
parent.btw = btwState{}
|
||||
// A scrollback print that was already scheduled may acknowledge after the
|
||||
// side surface is active. The hidden model does not receive that unscoped
|
||||
// acknowledgement, so clear its print latch and rebuild on return.
|
||||
parent.printInFlight = false
|
||||
parent.flushQueue = nil
|
||||
|
||||
side := parent
|
||||
side.activeSession = fork
|
||||
side.sessionEvents = events
|
||||
side.transcript = initialTranscript()
|
||||
side.transcript = appendRow(side.transcript, rowSystem, "BTW conversation · isolated from the main session · /btw or Ctrl+C to return")
|
||||
if clearedLoops > 0 {
|
||||
side.transcript = appendRow(side.transcript, rowSystem, fmt.Sprintf("Stopped %d main-session loop(s) before opening this conversation.", clearedLoops))
|
||||
}
|
||||
side.transcript = appendTranscriptRowsDedup(side.transcript, transcriptRowsFromSessionEvents(events))
|
||||
side.printInFlight = false
|
||||
side.flushQueue = nil
|
||||
side.resetFlushFrontier("· btw conversation ·")
|
||||
side.pending = false
|
||||
side.exiting = false
|
||||
side.runCancel = nil
|
||||
side.activeRunID = 0
|
||||
side.runID = maxInt(parent.runID+btwRunIDGap, parent.btwRunIDSeq)
|
||||
side.flushRunIDs = map[int]string{}
|
||||
side.liveUsageCounts = map[int]int{}
|
||||
side.usageTracker = usage.NewTracker(usage.TrackerOptions{Registry: &side.modelCatalog, Now: side.now})
|
||||
side.lastUsage = usage.Normalized{}
|
||||
side.lastUsageSeen = false
|
||||
side.unpricedRequests = 0
|
||||
side.unpricedTokens = 0
|
||||
side.compactRequests = 0
|
||||
side.compactFrame = 0
|
||||
side.lastCompactResult = nil
|
||||
side.lastCompactError = ""
|
||||
side.turnLatencySum = 0
|
||||
side.turnLatencyCount = 0
|
||||
side.turnTTFTSum = 0
|
||||
side.turnTTFTCount = 0
|
||||
side.pendingPermission = nil
|
||||
side.pendingAskUser = nil
|
||||
side.pendingSpecReview = nil
|
||||
side.queuedMessage = ""
|
||||
side.lastPrompt = ""
|
||||
side.lastImages = nil
|
||||
side.lastImageLabels = nil
|
||||
side.lastDocuments = nil
|
||||
side.inputHistory = nil
|
||||
side.historyIdx = 0
|
||||
side.historyDraft = ""
|
||||
if question == "" {
|
||||
side.pendingImages = nil
|
||||
side.pendingImageLabels = nil
|
||||
side.pendingDocuments = nil
|
||||
}
|
||||
side.loops = nil
|
||||
side.activeLoopID = ""
|
||||
side.loopTicking = false
|
||||
side.specialists.clear()
|
||||
side.plan.clear()
|
||||
side.planDetailGen++
|
||||
side.streamingText = nil
|
||||
side.streamingReasoning = ""
|
||||
side.streamingReasoningExpanded = false
|
||||
side.clearStreamingToolCall()
|
||||
side.resetStreamingFade()
|
||||
side.btw = btwState{
|
||||
active: true,
|
||||
parent: &parent,
|
||||
sideRunIDBase: side.runID,
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return side, nil
|
||||
}
|
||||
return side.launchPrompt(question)
|
||||
}
|
||||
|
||||
func (m model) leaveBTW() (model, tea.Cmd) {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m, nil
|
||||
}
|
||||
if m.pending {
|
||||
return m.appendSystemNotice("A BTW response is still running. Press Esc twice to cancel it, or wait for it to finish before returning."), nil
|
||||
}
|
||||
if len(m.flushRunIDs) > 0 {
|
||||
return m.appendSystemNotice("The cancelled BTW response is still saving its session events. Wait for it to finish before returning."), nil
|
||||
}
|
||||
if m.compactInFlight {
|
||||
return m.appendSystemNotice("BTW compaction is still running. Wait for it to finish before returning."), nil
|
||||
}
|
||||
m, _ = m.clearLoopsForSessionSwitch()
|
||||
parent := *m.btw.parent
|
||||
parent.btwRunIDSeq = maxInt(parent.btwRunIDSeq, m.runID)
|
||||
parent.btw = btwState{}
|
||||
// A hidden parent completion may have scheduled an unscoped git-sweep result
|
||||
// that landed on the side surface. Re-run it after restoring the parent so
|
||||
// its FILES sidebar cannot stay permanently marked in-flight or stale.
|
||||
parent.gitSweepInFlight = false
|
||||
var sweepCmd tea.Cmd
|
||||
parent, sweepCmd = parent.maybeGitSweep()
|
||||
// The side surface owns its own spinner tick chain. If the hidden parent is
|
||||
// still active, explicitly restart that chain after restoring it.
|
||||
parent.spinnerTicking = false
|
||||
var spinnerCmd tea.Cmd
|
||||
if parent.pending || parent.compactInFlight || parent.doctorInFlight || parent.sidebarHasAgents() || parent.aimlapiOnboardAnimating() {
|
||||
parent.spinnerTicking = true
|
||||
spinnerCmd = parent.spinner.Tick
|
||||
}
|
||||
parent.transcript = reduceTranscript(parent.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "Returned from the isolated BTW conversation. Its messages were not added to this session.",
|
||||
})
|
||||
parent.resetFlushFrontier("· returned from btw ·")
|
||||
return parent, batchCommands(sweepCmd, spinnerCmd)
|
||||
}
|
||||
|
||||
func btwCommandUnavailable(command parsedCommand) bool {
|
||||
arg := strings.ToLower(strings.TrimSpace(command.text))
|
||||
switch command.kind {
|
||||
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop,
|
||||
commandRewind, commandCompact, commandSTTModel, commandMCP:
|
||||
return true
|
||||
case commandModel:
|
||||
return arg != "list" && arg != "ls"
|
||||
case commandProvider:
|
||||
return arg != "status"
|
||||
case commandTurns, commandProfile:
|
||||
return arg != "" && arg != "status"
|
||||
case commandTheme:
|
||||
return arg != "list"
|
||||
case commandConfig:
|
||||
return arg != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resizeBTWParent carries terminal geometry into the hidden parent. Window-size
|
||||
// messages have no run ID, so normal BTW routing intentionally leaves them on
|
||||
// the visible side surface; copying the layout fields prevents stale wrapping
|
||||
// after the parent is restored.
|
||||
func (m model) resizeBTWParent(msg tea.WindowSizeMsg) model {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m
|
||||
}
|
||||
parent := *m.btw.parent
|
||||
parent.width = msg.Width
|
||||
parent.height = msg.Height
|
||||
parent = parent.clearHover()
|
||||
parent.lineAges = nil
|
||||
parent.lastStreamActivity = parent.now()
|
||||
parent.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
|
||||
m.btw.parent = &parent
|
||||
return m
|
||||
}
|
||||
|
||||
func btwTitle(parent string) string {
|
||||
parent = strings.TrimSpace(parent)
|
||||
if parent == "" {
|
||||
return "BTW conversation"
|
||||
}
|
||||
return parent + " (BTW)"
|
||||
}
|
||||
|
||||
// routeBTWParentMessage lets a main-session run finish while the side surface is
|
||||
// active. Side run IDs start far above the captured parent's counter, so messages
|
||||
// below that boundary unambiguously belong to the hidden parent model.
|
||||
func (m model) routeBTWParentMessage(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m, nil, false
|
||||
}
|
||||
if title, ok := msg.(sessionTitleGeneratedMsg); ok {
|
||||
if title.sessionID != m.btw.parent.activeSession.SessionID {
|
||||
return m, nil, false
|
||||
}
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
runID, ok := btwMessageRunID(msg)
|
||||
if !ok || runID <= 0 || runID >= m.btw.sideRunIDBase {
|
||||
return m, nil, false
|
||||
}
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
|
||||
func (m model) routeBTWMessageToParent(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
parentNext, cmd := m.btw.parent.updateModel(msg)
|
||||
parent, ok := parentNext.(model)
|
||||
if !ok {
|
||||
return m, cmd, true
|
||||
}
|
||||
parent.btw = btwState{}
|
||||
m.btw.parent = &parent
|
||||
switch msg.(type) {
|
||||
case permissionRequestMsg, askUserRequestMsg:
|
||||
if !m.btw.parentNeedsInput {
|
||||
m.btw.parentNeedsInput = true
|
||||
m = m.appendSystemNotice("The main session needs your input. Return with /btw or Ctrl+C to respond.")
|
||||
}
|
||||
case agentResponseMsg:
|
||||
m.btw.parentNeedsInput = parent.pendingPermission != nil || parent.pendingAskUser != nil
|
||||
}
|
||||
return m, cmd, true
|
||||
}
|
||||
|
||||
func btwMessageRunID(msg tea.Msg) (int, bool) {
|
||||
switch typed := msg.(type) {
|
||||
case agentTextMsg:
|
||||
return typed.runID, true
|
||||
case agentReasoningMsg:
|
||||
return typed.runID, true
|
||||
case agentUsageMsg:
|
||||
return typed.runID, true
|
||||
case agentResponseMsg:
|
||||
return typed.runID, true
|
||||
case agentRowMsg:
|
||||
return typed.runID, true
|
||||
case toolCallStreamStartMsg:
|
||||
return typed.runID, true
|
||||
case toolCallStreamDeltaMsg:
|
||||
return typed.runID, true
|
||||
case planUpdateMsg:
|
||||
return typed.runID, true
|
||||
case specialistStartMsg:
|
||||
return typed.runID, true
|
||||
case specialistCompleteMsg:
|
||||
return typed.runID, true
|
||||
case specialistProgressMsg:
|
||||
return typed.runID, true
|
||||
case swarmSessionsMsg:
|
||||
return typed.runID, true
|
||||
case permissionRequestMsg:
|
||||
return typed.runID, true
|
||||
case askUserRequestMsg:
|
||||
return typed.runID, true
|
||||
case recapGeneratedMsg:
|
||||
return typed.runID, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func newBTWTestModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
parent, err := store.Create(sessions.CreateInput{
|
||||
SessionID: "main-session",
|
||||
Title: "Main task",
|
||||
Cwd: "/repo",
|
||||
ModelID: "test-model",
|
||||
Provider: "test-provider",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create parent session: %v", err)
|
||||
}
|
||||
for _, payload := range []map[string]any{
|
||||
{"role": "user", "content": "implement the main task"},
|
||||
{"role": "assistant", "content": "working on it"},
|
||||
} {
|
||||
if _, err := store.AppendEvent(parent.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: payload}); err != nil {
|
||||
t.Fatalf("append parent event: %v", err)
|
||||
}
|
||||
}
|
||||
loaded, err := store.Get(parent.SessionID)
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("load parent session: session=%#v err=%v", loaded, err)
|
||||
}
|
||||
events, err := store.ReadEvents(parent.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("read parent events: %v", err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: "/repo",
|
||||
ModelName: "test-model",
|
||||
ProviderName: "test-provider",
|
||||
SessionStore: store,
|
||||
})
|
||||
m.activeSession = *loaded
|
||||
m.sessionEvents = events
|
||||
m.transcript = appendTranscriptRowsDedup(initialTranscript(), transcriptRowsFromSessionEvents(events))
|
||||
return m
|
||||
}
|
||||
|
||||
func TestBTWCommandParsesInlineQuestion(t *testing.T) {
|
||||
got := parseCommand("/btw double-check the approach")
|
||||
if got.kind != commandBTW || got.text != "double-check the approach" {
|
||||
t.Fatalf("parseCommand(/btw ...) = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCreatesIsolatedForkAndReturnsWithoutMerge(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
parentID := m.activeSession.SessionID
|
||||
parentEventCount := len(m.sessionEvents)
|
||||
|
||||
side, cmd := m.handleBTWCommand("")
|
||||
if cmd != nil {
|
||||
t.Fatal("bare /btw should not start an agent run")
|
||||
}
|
||||
if !side.btw.active || side.btw.parent == nil {
|
||||
t.Fatal("expected active BTW state with saved parent")
|
||||
}
|
||||
if side.activeSession.SessionKind != sessions.SessionKindSide || side.activeSession.ParentSessionID != parentID {
|
||||
t.Fatalf("side metadata = %#v", side.activeSession)
|
||||
}
|
||||
if side.activeSession.Tag != btwSessionTag {
|
||||
t.Fatalf("side tag = %q, want %q", side.activeSession.Tag, btwSessionTag)
|
||||
}
|
||||
if len(side.sessionEvents) <= parentEventCount {
|
||||
t.Fatalf("side should contain copied context plus fork marker: got %d events", len(side.sessionEvents))
|
||||
}
|
||||
if !strings.Contains(side.sessionPrompt("question"), btwContextBoundary) {
|
||||
t.Fatal("side prompt is missing the inherited-context boundary")
|
||||
}
|
||||
|
||||
updated, err := side.appendSessionEvent(sessions.EventMessage, map[string]any{
|
||||
"role": "assistant", "content": "side-only answer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("append side event: %v", err)
|
||||
}
|
||||
returned, _ := updated.leaveBTW()
|
||||
if returned.activeSession.SessionID != parentID {
|
||||
t.Fatalf("returned session = %q, want %q", returned.activeSession.SessionID, parentID)
|
||||
}
|
||||
if len(returned.sessionEvents) != parentEventCount {
|
||||
t.Fatalf("side events merged into parent: got %d events, want %d", len(returned.sessionEvents), parentEventCount)
|
||||
}
|
||||
parentEvents, err := returned.sessionStore.ReadEvents(parentID)
|
||||
if err != nil {
|
||||
t.Fatalf("read parent after return: %v", err)
|
||||
}
|
||||
for _, event := range parentEvents {
|
||||
if strings.Contains(string(event.Payload), "side-only answer") {
|
||||
t.Fatal("side-only event was persisted into the parent session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCanOpenWhileParentRunContinues(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.pending = true
|
||||
m.runID = 7
|
||||
m.activeRunID = 7
|
||||
|
||||
side, _ := m.handleBTWCommand("")
|
||||
if side.pending || side.activeRunID != 0 {
|
||||
t.Fatalf("side inherited parent run state: pending=%v activeRunID=%d", side.pending, side.activeRunID)
|
||||
}
|
||||
if side.btw.parent == nil || !side.btw.parent.pending || side.btw.parent.activeRunID != 7 {
|
||||
t.Fatalf("parent run was not preserved: %#v", side.btw.parent)
|
||||
}
|
||||
|
||||
routed, _, ok := side.routeBTWParentMessage(agentTextMsg{runID: 7, delta: "main progress"})
|
||||
if !ok {
|
||||
t.Fatal("parent run message was not routed")
|
||||
}
|
||||
if strings.Contains(routed.streamingTextString(), "main progress") {
|
||||
t.Fatal("parent streaming output leaked into the side transcript")
|
||||
}
|
||||
if routed.btw.parent == nil || !strings.Contains(routed.btw.parent.streamingTextString(), "main progress") {
|
||||
t.Fatal("parent streaming output was not retained on the hidden parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWInlineQuestionStartsSideRun(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.provider = &fakeProvider{}
|
||||
|
||||
side, cmd := m.handleBTWCommand("double-check this assumption")
|
||||
if cmd == nil || !side.pending {
|
||||
t.Fatalf("inline /btw question did not start a run: pending=%v cmd=%v", side.pending, cmd)
|
||||
}
|
||||
if side.lastPrompt != "double-check this assumption" {
|
||||
t.Fatalf("side last prompt = %q", side.lastPrompt)
|
||||
}
|
||||
if side.btw.parent == nil || side.btw.parent.pending {
|
||||
t.Fatal("idle parent should remain idle while the side run starts")
|
||||
}
|
||||
parentEvents, err := side.sessionStore.ReadEvents(side.btw.parent.activeSession.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("read parent events: %v", err)
|
||||
}
|
||||
for _, event := range parentEvents {
|
||||
if strings.Contains(string(event.Payload), "double-check this assumption") {
|
||||
t.Fatal("inline side question was written into the parent session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWRejectsBeforeMainSessionStarts(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{SessionStore: sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})})
|
||||
got, cmd := m.handleBTWCommand("")
|
||||
if cmd != nil {
|
||||
t.Fatal("pre-session /btw should not start work")
|
||||
}
|
||||
if got.btw.active || !transcriptContains(got.transcript, "Start the main session") {
|
||||
t.Fatalf("unexpected pre-session /btw result: active=%v transcript=%#v", got.btw.active, got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWBlocksCommandsThatWouldReplaceItsSession(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
updated, _ := side.dispatchCommand(parseCommand("/new"))
|
||||
got := updated.(model)
|
||||
if !got.btw.active || got.activeSession.SessionKind != sessions.SessionKindSide {
|
||||
t.Fatalf("/new replaced the active BTW session: active=%v metadata=%#v", got.btw.active, got.activeSession)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "unavailable in a BTW conversation") {
|
||||
t.Fatalf("missing blocked-command guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWBlocksPersistentConfigurationCommands(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/model other",
|
||||
"/provider add",
|
||||
"/turns 100",
|
||||
"/profile fast",
|
||||
"/theme dark",
|
||||
"/config recaps off",
|
||||
"/stt-model",
|
||||
"/mcp",
|
||||
"/rewind",
|
||||
"/compact",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
updated, _ := side.dispatchCommand(parseCommand(input))
|
||||
got := updated.(model)
|
||||
if !got.btw.active || got.activeSession.SessionKind != sessions.SessionKindSide {
|
||||
t.Fatalf("%s escaped the active BTW session: active=%v metadata=%#v", input, got.btw.active, got.activeSession)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "unavailable in a BTW conversation") {
|
||||
t.Fatalf("%s missing blocked-command guidance: %#v", input, got.transcript)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWAllowsReadOnlyConfigurationCommands(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/model list",
|
||||
"/provider status",
|
||||
"/turns",
|
||||
"/profile status",
|
||||
"/theme list",
|
||||
"/config",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
updated, _ := side.dispatchCommand(parseCommand(input))
|
||||
got := updated.(model)
|
||||
if transcriptContains(got.transcript, "unavailable in a BTW conversation") {
|
||||
t.Fatalf("%s was blocked even though it is read-only", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWExitBlockedWhileParentRunActive(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
updated, cmd := side.dispatchCommand(parseCommand("/exit"))
|
||||
got := updated.(model)
|
||||
if cmd != nil || got.exiting || !got.btw.active {
|
||||
t.Fatalf("/exit escaped BTW while parent was active: cmd=%v exiting=%v active=%v", cmd, got.exiting, got.btw.active)
|
||||
}
|
||||
if got.btw.parent == nil || !got.btw.parent.pending {
|
||||
t.Fatalf("hidden parent run was not preserved: %#v", got.btw.parent)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "main session is still running") {
|
||||
t.Fatalf("missing active-parent exit guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWReturnRestartsParentSpinner(t *testing.T) {
|
||||
for _, reducedMotion := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "animated", true: "reduced-motion"}[reducedMotion], func(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.reducedMotion = reducedMotion
|
||||
m.pending = true
|
||||
m.spinnerTicking = true
|
||||
side, _ := m.handleBTWCommand("")
|
||||
side.spinnerTicking = false
|
||||
|
||||
returned, cmd := side.leaveBTW()
|
||||
if !returned.pending || !returned.spinnerTicking {
|
||||
t.Fatalf("parent spinner was not restarted: pending=%v ticking=%v", returned.pending, returned.spinnerTicking)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("returning to an active parent did not schedule a spinner tick")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCancelledRunFlushesBeforeReturnAndRunIDsStayUnique(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.provider = &fakeProvider{}
|
||||
|
||||
side1, _ := m.handleBTWCommand("first side question")
|
||||
firstRunID := side1.activeRunID
|
||||
side1.cancelRun()
|
||||
blocked, _ := side1.leaveBTW()
|
||||
if !blocked.btw.active || !transcriptContains(blocked.transcript, "still saving its session events") {
|
||||
t.Fatal("BTW returned before the cancelled side run finished flushing")
|
||||
}
|
||||
|
||||
flushedModel, _ := side1.updateModel(agentResponseMsg{runID: firstRunID})
|
||||
flushed := flushedModel.(model)
|
||||
if len(flushed.flushRunIDs) != 0 {
|
||||
t.Fatalf("cancelled side run did not drain: %#v", flushed.flushRunIDs)
|
||||
}
|
||||
parent, _ := flushed.leaveBTW()
|
||||
side2, _ := parent.handleBTWCommand("second side question")
|
||||
if side2.activeRunID == firstRunID {
|
||||
t.Fatalf("BTW run ID was reused across re-entry: %d", firstRunID)
|
||||
}
|
||||
|
||||
updated, _ := side2.updateModel(agentResponseMsg{
|
||||
runID: firstRunID,
|
||||
rows: []transcriptRow{{kind: rowAssistant, text: "answer to the FIRST question"}},
|
||||
})
|
||||
got := updated.(model)
|
||||
if !got.pending || transcriptContains(got.transcript, "answer to the FIRST question") {
|
||||
t.Fatal("a stale side response hijacked the next BTW run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWRejectsExplicitResumeOfSideSession(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
if _, err := m.resolveResumeSession(side.activeSession.SessionID); err == nil || !strings.Contains(err.Error(), "not resumable") {
|
||||
t.Fatalf("explicit side-session resume error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCannotStartDuringDeferredExit(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.exiting = true
|
||||
|
||||
got, cmd := m.handleBTWCommand("should not run")
|
||||
if cmd != nil || got.btw.active {
|
||||
t.Fatalf("BTW started during deferred exit: active=%v cmd=%v", got.btw.active, cmd)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "cannot start now") {
|
||||
t.Fatalf("missing deferred-exit guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWStatusAndHelpStayVisible(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
if status := plainRender(t, side.statusLine(100)); !strings.Contains(status, "BTW") {
|
||||
t.Fatalf("status line has no persistent BTW indicator: %q", status)
|
||||
}
|
||||
groups := side.buildKeybindingGroups()
|
||||
if got := groups[0].bindings[3].desc; !strings.Contains(got, "return to the main session") {
|
||||
t.Fatalf("BTW Ctrl+C help = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWResizeUpdatesHiddenParent(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.width = 80
|
||||
m.height = 24
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
updated, _ := side.updateModel(tea.WindowSizeMsg{Width: 120, Height: 50})
|
||||
got := updated.(model)
|
||||
if got.btw.parent == nil || got.btw.parent.width != 120 || got.btw.parent.height != 50 {
|
||||
t.Fatalf("hidden parent kept stale geometry: %#v", got.btw.parent)
|
||||
}
|
||||
returned, _ := got.leaveBTW()
|
||||
if returned.width != 120 || returned.height != 50 {
|
||||
t.Fatalf("restored parent geometry = %dx%d, want 120x50", returned.width, returned.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWUsesIndependentUsageTracker(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
parentTracker := m.usageTracker
|
||||
side, _ := m.handleBTWCommand("")
|
||||
if side.usageTracker == nil || side.usageTracker == parentTracker {
|
||||
t.Fatal("BTW conversation shared the main session usage tracker")
|
||||
}
|
||||
returned, _ := side.leaveBTW()
|
||||
if returned.usageTracker != parentTracker {
|
||||
t.Fatal("returning from BTW did not restore the main session usage tracker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWFailedForkKeepsMainSessionLoops(t *testing.T) {
|
||||
badRoot := filepath.Join(t.TempDir(), "sessions")
|
||||
if err := os.WriteFile(badRoot, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{SessionStore: sessions.NewStore(sessions.StoreOptions{RootDir: badRoot})})
|
||||
m.activeSession = sessions.Metadata{SessionID: "main-session"}
|
||||
m.loops = []*loopState{{id: "loop-1"}}
|
||||
|
||||
got, _ := m.handleBTWCommand("")
|
||||
if len(got.loops) != 1 || got.loops[0].id != "loop-1" {
|
||||
t.Fatalf("failed BTW fork cleared main-session loops: %#v", got.loops)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "Could not open BTW conversation") {
|
||||
t.Fatalf("missing fork failure notice: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCommandReturnsToParentSession(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
parentID := m.activeSession.SessionID
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
updated, cmd := side.dispatchCommand(parseCommand("/btw"))
|
||||
returned := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("returning from an idle BTW conversation should not start work")
|
||||
}
|
||||
if returned.btw.active || returned.btw.parent != nil {
|
||||
t.Fatalf("BTW state remained active after /btw: %#v", returned.btw)
|
||||
}
|
||||
if returned.activeSession.SessionID != parentID {
|
||||
t.Fatalf("returned session = %q, want parent %q", returned.activeSession.SessionID, parentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCtrlCDuringRunDoesNotClearDraft(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
side.pending = true
|
||||
side.input.SetValue("keep this draft")
|
||||
|
||||
updated, cmd := side.handleCtrlC()
|
||||
got := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("Ctrl+C should not start a command while a BTW response is running")
|
||||
}
|
||||
if !got.btw.active {
|
||||
t.Fatal("Ctrl+C returned from BTW while its response was still running")
|
||||
}
|
||||
if got.composerValue() != "keep this draft" {
|
||||
t.Fatalf("Ctrl+C cleared the in-flight BTW draft: %q", got.composerValue())
|
||||
}
|
||||
if !transcriptContains(got.transcript, "BTW response is still running") {
|
||||
t.Fatalf("missing in-flight return guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
@@ -214,12 +214,6 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -697,14 +691,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ const (
|
||||
commandCopy
|
||||
commandExport
|
||||
commandNew
|
||||
commandBTW
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandVoice
|
||||
@@ -193,13 +192,6 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Start a fresh session; the current one stays resumable via /resume.",
|
||||
kind: commandNew,
|
||||
},
|
||||
{
|
||||
name: "/btw",
|
||||
usage: "/btw [question]",
|
||||
group: commandGroupSession,
|
||||
description: "Open an isolated side conversation; run /btw again to return.",
|
||||
kind: commandBTW,
|
||||
},
|
||||
{
|
||||
name: "/search",
|
||||
aliases: []string{"/find"},
|
||||
@@ -261,7 +253,6 @@ 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,31 +985,6 @@ 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,29 +348,12 @@ 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 {
|
||||
rowIndex := m.lastRowIndexForFile(path)
|
||||
m.setSelectedFile(path)
|
||||
if offset, ok := m.scrollOffsetForTranscriptRow(rowIndex); ok {
|
||||
m.selectedFile = path
|
||||
if offset, ok := m.scrollOffsetForTranscriptRow(m.lastRowIndexForFile(path)); ok {
|
||||
m.chatScrollOffset = offset
|
||||
if offset == 0 {
|
||||
m.chatBodyLines = 0
|
||||
|
||||
+9
-61
@@ -69,6 +69,13 @@ 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.
|
||||
@@ -80,14 +87,10 @@ func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
// frontier themselves; this is a safety net).
|
||||
m.flushed = len(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:])
|
||||
rc := buildRowContext(m.transcript)
|
||||
width := chatWidth(m.width)
|
||||
batch := []string{}
|
||||
previousKind, havePreviousKind := m.flushedPreviousKind, m.flushedHavePreviousKind
|
||||
previousKind, havePreviousKind := previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
|
||||
for m.flushed < len(m.transcript) {
|
||||
row := m.transcript[m.flushed]
|
||||
if !m.settledRow(row, rc) {
|
||||
@@ -97,15 +100,6 @@ 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
|
||||
@@ -121,15 +115,6 @@ 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"))
|
||||
}
|
||||
@@ -154,11 +139,6 @@ 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.
|
||||
@@ -167,35 +147,3 @@ 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
|
||||
}
|
||||
+4
-147
@@ -36,7 +36,7 @@ func TestSettledRowsAdvanceFrontierAndLeaveLiveView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenAdvancesFrontierAndCachesSettledRows(t *testing.T) {
|
||||
func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{AltScreen: true})
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
m = updated.(model)
|
||||
@@ -47,155 +47,12 @@ func TestAltScreenAdvancesFrontierAndCachesSettledRows(t *testing.T) {
|
||||
if cmd != nil {
|
||||
t.Fatal("alt-screen mode should not print rows into native scrollback")
|
||||
}
|
||||
if next.flushed != len(next.transcript) {
|
||||
t.Fatalf("alt-screen mode should advance the frontier to %d, got %d", len(next.transcript), next.flushed)
|
||||
if next.flushed != 0 {
|
||||
t.Fatalf("alt-screen mode should keep the flush frontier unchanged, got %d", next.flushed)
|
||||
}
|
||||
view := viewString(next.View())
|
||||
if !strings.Contains(view, "hello there") || !strings.Contains(view, "noted") {
|
||||
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")
|
||||
t.Fatalf("settled rows should remain in the managed alt-screen view, got %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,10 +31,6 @@ type keybindingGroup struct {
|
||||
// Sourced from the real key cases in model.go (Update) — not invented. When a
|
||||
// binding is added/changed there, update this method too.
|
||||
func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
ctrlCDescription := "cancel the run, then quit"
|
||||
if m.btw.active {
|
||||
ctrlCDescription = "return to the main session when the BTW run is idle"
|
||||
}
|
||||
return []keybindingGroup{
|
||||
{
|
||||
title: "Chat",
|
||||
@@ -42,7 +38,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
{"Enter", "send the message"},
|
||||
{"Shift+Enter / Alt+Enter", "insert a newline (multi-line compose)"},
|
||||
{"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Ctrl+C", ctrlCDescription},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"Ctrl+X then letter", "common slash commands (m=/model, p=/provider, r=/resume, \u2026)"},
|
||||
{"Ctrl+X ?", "list every Ctrl+X slash shortcut"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
|
||||
+12
-116
@@ -103,11 +103,6 @@ type model struct {
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
btw btwState
|
||||
// btwRunIDSeq is the highest run ID issued by any completed or abandoned BTW
|
||||
// surface. It survives returning to the parent so a late message from an old
|
||||
// side run can never match a run in a later BTW conversation.
|
||||
btwRunIDSeq int
|
||||
// titledSessions records session ids for which a model-generated title has
|
||||
// already been attempted this process, so a finished turn re-fires the title
|
||||
// generator at most once per session (even before its async result lands).
|
||||
@@ -312,23 +307,16 @@ type model struct {
|
||||
chatBodyLines int
|
||||
|
||||
// Flush-frontier state (see flush.go). In inline mode, transcript[:flushed]
|
||||
// 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.
|
||||
// is already in native scrollback; in alt-screen mode this frontier stays
|
||||
// idle so history cannot reveal prior shell output.
|
||||
// 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
|
||||
flushedPreviousKind rowKind
|
||||
flushedHavePreviousKind bool
|
||||
flushQueue []string
|
||||
printInFlight bool
|
||||
headerPrinted bool
|
||||
altScreenSettledItems []transcriptBodyItem
|
||||
altScreenSettledWidth int
|
||||
altScreenSettledFrontier int
|
||||
flushed int
|
||||
flushedAny bool
|
||||
flushQueue []string
|
||||
printInFlight bool
|
||||
headerPrinted bool
|
||||
|
||||
// Composer input history (shell-style ↑/↓ recall of submitted inputs).
|
||||
// lastPrompt is the verbatim text of the most recent submitted prompt, so
|
||||
@@ -408,9 +396,8 @@ 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
|
||||
transcriptInteraction *transcriptRenderInteraction
|
||||
mouseReleased bool
|
||||
transcriptSelection transcriptSelectionState
|
||||
// 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
|
||||
@@ -689,17 +676,6 @@ type pendingPermissionPrompt struct {
|
||||
// resting approval choice. Moved by ↑/↓/Tab; confirmed by Enter or a click.
|
||||
// Hotkeys resolve the matching request-provided option directly.
|
||||
cursor int
|
||||
// typing is true once the user chose "tell Zero what to do differently": the
|
||||
// card replaces its option list with a free-text field (sharing the composer
|
||||
// input, like the ask_user questionnaire). Submitting sends a Deny decision
|
||||
// whose Reason is the typed text, so the model reads it as the tool result and
|
||||
// adjusts course in the same turn instead of the run being cancelled.
|
||||
typing bool
|
||||
// savedDraft holds whatever was in the shared composer input when feedback
|
||||
// mode was entered. The field is cleared for typing and restored on both
|
||||
// submit and cancel, so a half-typed or queued next-turn message survives the
|
||||
// detour (permissionRequestMsg, unlike ask_user, does not clear the composer).
|
||||
savedDraft string
|
||||
}
|
||||
|
||||
// askUserRequestMsg is the TUI-loop equivalent of permissionRequestMsg: the
|
||||
@@ -860,7 +836,6 @@ func newModel(ctx context.Context, options Options) model {
|
||||
usageTracker: usageTracker,
|
||||
transcript: initialTranscript(),
|
||||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||||
transcriptInteraction: &transcriptRenderInteraction{},
|
||||
prService: prService,
|
||||
prState: prService.GetState(),
|
||||
input: input,
|
||||
@@ -1054,14 +1029,6 @@ func (m model) shutdownLSPManager() {
|
||||
}
|
||||
|
||||
func (m model) handleCtrlC() (tea.Model, tea.Cmd) {
|
||||
if m.btw.active {
|
||||
if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
}
|
||||
return m.leaveBTW()
|
||||
}
|
||||
if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
@@ -1141,12 +1108,6 @@ func batchCommands(cmds ...tea.Cmd) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if size, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m = m.resizeBTWParent(size)
|
||||
}
|
||||
if next, cmd, routed := m.routeBTWParentMessage(msg); routed {
|
||||
return next, cmd
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case composerBlinkMsg:
|
||||
switch {
|
||||
@@ -1450,11 +1411,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.pendingAskUser != nil {
|
||||
return m.escapeAskUser()
|
||||
}
|
||||
// Esc in the permission feedback field steps back to the option list
|
||||
// rather than resolving, so a stray keystroke is recoverable.
|
||||
if m.pendingPermission != nil && m.pendingPermission.typing {
|
||||
return m.cancelPermissionTyping()
|
||||
}
|
||||
if m.pendingSpecReview != nil {
|
||||
m.burstCount = 0
|
||||
return m.cancelSpecReview()
|
||||
@@ -1497,7 +1453,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.setSelectedFile("")
|
||||
m.selectedFile = ""
|
||||
return m, nil
|
||||
}
|
||||
if m.hasQueuedMessage() {
|
||||
@@ -1675,16 +1631,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
case keyBackspace(msg):
|
||||
// In permission feedback mode Backspace is a plain edit of the feedback
|
||||
// text. This case runs before the typing branch below and, on an empty
|
||||
// field (feedback mode clears the composer), would otherwise fall to the
|
||||
// removeLastAttachment path and silently drop a staged image/doc that
|
||||
// savedDraft does not restore. Route it to the shared input instead.
|
||||
if m.pendingPermission != nil && m.pendingPermission.typing {
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
@@ -1880,15 +1826,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.handleSpecReviewKey(msg)
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
// Feedback mode: a printable keystroke (and editing keys like
|
||||
// backspace) types into the shared composer input, mirroring the
|
||||
// ask_user free-text path above. Enter/Esc/↑/↓ were already handled
|
||||
// earlier in this switch; the remaining keys reach the input here.
|
||||
if m.pendingPermission.typing {
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
m.burstCount = 0
|
||||
return m.handlePermissionKey(msg)
|
||||
}
|
||||
@@ -2513,12 +2450,7 @@ 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
|
||||
@@ -2819,16 +2751,6 @@ func (m model) footerView(width int) string {
|
||||
footer.WriteString(m.statusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// A focused permission prompt owns the keyboard: its options (and the feedback
|
||||
// field) consume every key, so the composer is inert. Suppress it and the idle
|
||||
// hints/plan panel like the ask_user modal above, keeping only the status line.
|
||||
// The card itself renders in the transcript body. This also keeps the shared
|
||||
// input from echoing in two places once "tell Zero what to do differently"
|
||||
// opens the on-card feedback field.
|
||||
if m.pendingPermission != nil {
|
||||
footer.WriteString(m.statusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// Pinned plan panel: sits directly above the composer so it stays visible
|
||||
// while the transcript scrolls underneath (a streaming turn no longer pushes
|
||||
// the plan off-screen). Budgeted to at most a third of the screen height; a
|
||||
@@ -3987,22 +3909,13 @@ func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
key := strings.ToLower(msg.String())
|
||||
for _, option := range permissionOptions(m.pendingPermission.request) {
|
||||
if option.hotkey == key {
|
||||
return m.choosePermissionOption(option.choice)
|
||||
return m.resolvePermission(option.choice)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) resolvePermission(decision permissionDecision) (tea.Model, tea.Cmd) {
|
||||
return m.resolvePermissionWithReason(decision, permissionDecisionReason(decision))
|
||||
}
|
||||
|
||||
// resolvePermissionWithReason resolves the pending prompt with an explicit reason
|
||||
// string. It backs both the fixed-label choices (reason = permissionDecisionReason)
|
||||
// and the free-text "tell Zero what to do differently" path, where the reason is
|
||||
// the user's typed instruction and the action is Deny so the agent surfaces it as
|
||||
// the tool result and keeps going.
|
||||
func (m model) resolvePermissionWithReason(decision permissionDecision, reason string) (tea.Model, tea.Cmd) {
|
||||
pending := m.pendingPermission
|
||||
if pending == nil {
|
||||
return m, nil
|
||||
@@ -4011,7 +3924,7 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
|
||||
if pending.decide != nil {
|
||||
pending.decide(agent.PermissionDecision{
|
||||
Action: decision,
|
||||
Reason: reason,
|
||||
Reason: permissionDecisionReason(decision),
|
||||
})
|
||||
}
|
||||
m.pendingPermission = nil
|
||||
@@ -4201,21 +4114,6 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
// dispatchCommand runs a parsed slash/prompt command after submit/leader
|
||||
// preamble (history, composer clear, leave-prompt disarm) has already run.
|
||||
func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
if m.btw.active && command.kind == commandExit && m.btw.parent != nil &&
|
||||
(m.btw.parent.pending || m.btw.parent.compactInFlight || len(m.btw.parent.flushRunIDs) > 0) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "The main session is still running. Return to it first with /btw or Ctrl+C before exiting.",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
if m.btw.active && btwCommandUnavailable(command) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: command.name + " is unavailable in a BTW conversation. Return to the main session first with /btw or Ctrl+C.",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
switch command.kind {
|
||||
case commandEmpty:
|
||||
return m, nil
|
||||
@@ -4254,8 +4152,6 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandBTW:
|
||||
return m.handleBTWCommand(command.text)
|
||||
case commandLoop:
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandExit:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -76,9 +74,7 @@ func clampPermissionCursor(cursor int, request agent.PermissionRequest) int {
|
||||
// the ends. A no-op when no permission prompt is pending. The cursor lives on the
|
||||
// pending prompt (a pointer), mirroring how the picker's selection moves.
|
||||
func (m model) movePermissionCursor(delta int) model {
|
||||
if m.pendingPermission == nil || m.pendingPermission.typing {
|
||||
// While typing feedback the arrow/Tab keys belong to the text field, not
|
||||
// the option list.
|
||||
if m.pendingPermission == nil {
|
||||
return m
|
||||
}
|
||||
n := len(permissionOptions(m.pendingPermission.request))
|
||||
@@ -91,67 +87,11 @@ func (m model) movePermissionCursor(delta int) model {
|
||||
}
|
||||
|
||||
// confirmPermissionCursor resolves the currently highlighted option. It is the
|
||||
// Enter-key counterpart to the a/y/d hotkeys and a mouse click. Confirming the
|
||||
// "tell Zero what to do differently" choice opens the inline feedback field
|
||||
// instead of resolving immediately.
|
||||
// Enter-key counterpart to the a/y/d hotkeys and a mouse click.
|
||||
func (m model) confirmPermissionCursor() (tea.Model, tea.Cmd) {
|
||||
if m.pendingPermission == nil {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission.typing {
|
||||
return m.submitPermissionFeedback()
|
||||
}
|
||||
option := permissionOptions(m.pendingPermission.request)[clampPermissionCursor(m.pendingPermission.cursor, m.pendingPermission.request)]
|
||||
return m.choosePermissionOption(option.choice)
|
||||
}
|
||||
|
||||
// choosePermissionOption applies a chosen decision. The cancel choice (the
|
||||
// "tell Zero what to do differently" row and its [n] hotkey) opens the inline
|
||||
// feedback field rather than aborting the run; every other choice resolves
|
||||
// immediately as before.
|
||||
func (m model) choosePermissionOption(choice permissionDecision) (tea.Model, tea.Cmd) {
|
||||
if m.pendingPermission == nil {
|
||||
return m, nil
|
||||
}
|
||||
if choice == permissionDecisionCancel {
|
||||
m.pendingPermission.typing = true
|
||||
// Preserve whatever the user had drafted/queued in the composer so it is
|
||||
// restored when they leave feedback mode (submit or cancel).
|
||||
m.pendingPermission.savedDraft = m.input.Value()
|
||||
m.input.SetValue("")
|
||||
return m, nil
|
||||
}
|
||||
return m.resolvePermission(choice)
|
||||
}
|
||||
|
||||
// submitPermissionFeedback ends the feedback field. Non-empty text is sent as a
|
||||
// Deny decision whose Reason is the text: the agent surfaces that as the tool
|
||||
// result (deniedPermissionResult) so the model reads the instruction and adjusts
|
||||
// in the same turn, rather than the run being cancelled. Empty text falls back to
|
||||
// a plain cancel, matching the option's prior behaviour.
|
||||
func (m model) submitPermissionFeedback() (tea.Model, tea.Cmd) {
|
||||
if m.pendingPermission == nil {
|
||||
return m, nil
|
||||
}
|
||||
feedback := strings.TrimSpace(m.input.Value())
|
||||
// Restore the composer draft the user had before entering feedback mode; the
|
||||
// feedback text itself is delivered via the decision Reason, not the composer.
|
||||
m.input.SetValue(m.pendingPermission.savedDraft)
|
||||
m.pendingPermission.typing = false
|
||||
if feedback == "" {
|
||||
return m.resolvePermission(permissionDecisionCancel)
|
||||
}
|
||||
return m.resolvePermissionWithReason(permissionDecisionDeny, feedback)
|
||||
}
|
||||
|
||||
// cancelPermissionTyping returns from the feedback field to the option list
|
||||
// without resolving, so Esc is a safe "I didn't mean to type" back-out.
|
||||
func (m model) cancelPermissionTyping() (tea.Model, tea.Cmd) {
|
||||
if m.pendingPermission == nil || !m.pendingPermission.typing {
|
||||
return m, nil
|
||||
}
|
||||
m.pendingPermission.typing = false
|
||||
m.input.SetValue(m.pendingPermission.savedDraft)
|
||||
m.pendingPermission.savedDraft = ""
|
||||
return m, nil
|
||||
return m.resolvePermission(option.choice)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func pendingPermissionModel(t *testing.T, decide func(agent.PermissionDecision)) model {
|
||||
@@ -74,7 +72,7 @@ func TestPermissionOptionsExposeApprovalCancelWhenSupplied(t *testing.T) {
|
||||
t.Fatalf("cancel option = %#v, want cancel on n", options[3])
|
||||
}
|
||||
|
||||
card, _ := renderFocusedPermissionPrompt(request, 3, false, "", 80)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 3, 80)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"continue without running it", "[d]", "tell Zero what to do differently", "[n]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -97,7 +95,7 @@ func TestPermissionOptionsExposeCommandPrefixApproval(t *testing.T) {
|
||||
if len(options) != 3 || options[1].choice != permissionDecisionAllowPrefix || options[1].hotkey != "p" {
|
||||
t.Fatalf("prefix option = %#v, want p hotkey in supplied order", options)
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 100)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, 100)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"allow `git status` in this session", "[p]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -116,7 +114,7 @@ func TestPermissionPromptMapsEscalatedSandboxReason(t *testing.T) {
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 96)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, 96)
|
||||
got := plainRender(t, card)
|
||||
if !strings.Contains(got, "This command needs to run outside the sandbox.") {
|
||||
t.Fatalf("permission card = %q, missing user-facing sandbox reason", got)
|
||||
@@ -141,7 +139,7 @@ func TestPermissionOptionsExposePersistentCommandPrefixApproval(t *testing.T) {
|
||||
if len(options) != 4 || options[2].choice != permissionDecisionAlwaysAllowPrefix || options[2].hotkey != "y" {
|
||||
t.Fatalf("persistent prefix option = %#v, want y hotkey in supplied order", options)
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 100)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, 100)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"always allow `git status`", "[y]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -159,7 +157,7 @@ func TestPermissionOptionsCanExposePatchCancelWithoutRecoverableDeny(t *testing.
|
||||
agent.PermissionDecisionCancel,
|
||||
},
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 80)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, 80)
|
||||
got := plainRender(t, card)
|
||||
if !strings.Contains(got, "tell Zero what to do differently") || !strings.Contains(got, "[n]") {
|
||||
t.Fatalf("permission card = %q, missing cancel option", got)
|
||||
@@ -182,7 +180,7 @@ func TestRequestPermissionsPromptUsesGrantLabelsAndEscDenies(t *testing.T) {
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 96)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, 96)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{
|
||||
"Grant requested permissions?",
|
||||
@@ -257,9 +255,7 @@ func TestPermissionHotkeysStillResolveDirectly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// feedbackRequest is a bash prompt whose decision set includes Cancel, so the
|
||||
// "tell Zero what to do differently" row (and its [n] hotkey) is present.
|
||||
func feedbackRequest() agent.PermissionRequest {
|
||||
func TestPermissionCancelHotkeyResolvesDirectly(t *testing.T) {
|
||||
request := testPromptPermissionRequest()
|
||||
request.ToolName = "bash"
|
||||
request.AvailableDecisions = []agent.PermissionDecisionAction{
|
||||
@@ -267,87 +263,21 @@ func feedbackRequest() agent.PermissionRequest {
|
||||
agent.PermissionDecisionDeny,
|
||||
agent.PermissionDecisionCancel,
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// The [n] "tell Zero what to do differently" hotkey no longer resolves cancel
|
||||
// immediately: it opens the inline feedback field and sends nothing yet.
|
||||
func TestPermissionTellDifferentlyOpensFeedbackField(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
got := []permissionDecision{}
|
||||
m := pendingPermissionModelWithRequest(t, request, func(d agent.PermissionDecision) {
|
||||
got = append(got, permissionDecision(d.Action))
|
||||
})
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
nm := next.(model)
|
||||
if nm.pendingPermission == nil || !nm.pendingPermission.typing {
|
||||
t.Fatalf("'n' should open the feedback field, got pending=%#v", nm.pendingPermission)
|
||||
if _, cmd := m.Update(testKeyText("n")); cmd != nil {
|
||||
t.Fatal("'n' should resolve synchronously")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("'n' must not resolve anything yet, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Typing an instruction and pressing Enter sends a Deny whose Reason is the
|
||||
// text, so the agent surfaces it to the model as the tool result.
|
||||
func TestPermissionFeedbackSubmitSendsDenyWithText(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
})
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
nm := typeRunes(t, next.(model), "use apply_patch instead")
|
||||
after, _ := nm.Update(testKey(tea.KeyEnter))
|
||||
am := after.(model)
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Enter should resolve exactly one decision, got %#v", got)
|
||||
}
|
||||
if got[0].Action != agent.PermissionDecisionDeny {
|
||||
t.Fatalf("feedback should resolve as Deny, got %s", got[0].Action)
|
||||
}
|
||||
if got[0].Reason != "use apply_patch instead" {
|
||||
t.Fatalf("Deny reason should carry the typed text, got %q", got[0].Reason)
|
||||
}
|
||||
if am.pendingPermission != nil {
|
||||
t.Fatal("prompt should be dismissed after submitting feedback")
|
||||
}
|
||||
}
|
||||
|
||||
// Enter with an empty field falls back to a plain cancel (the option's prior
|
||||
// meaning), so opening the field and changing your mind is safe.
|
||||
func TestPermissionFeedbackEmptySubmitCancels(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
})
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
after, _ := next.(model).Update(testKey(tea.KeyEnter))
|
||||
_ = after
|
||||
if len(got) != 1 || got[0].Action != agent.PermissionDecisionCancel {
|
||||
t.Fatalf("empty feedback should resolve Cancel, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc in the feedback field returns to the option list without resolving.
|
||||
func TestPermissionFeedbackEscReturnsToOptions(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
})
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
after, _ := typeRunes(t, next.(model), "half a thought").Update(testKey(tea.KeyEsc))
|
||||
am := after.(model)
|
||||
if am.pendingPermission == nil || am.pendingPermission.typing {
|
||||
t.Fatalf("Esc should return to the option list, got pending=%#v", am.pendingPermission)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("Esc must not resolve anything, got %#v", got)
|
||||
if len(got) != 1 || got[0] != permissionDecisionCancel {
|
||||
t.Fatalf("'n' should resolve cancel directly, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionRenderEmitsHighlightedClickableOffsets(t *testing.T) {
|
||||
request := agent.PermissionRequest{ToolName: "write_file", AvailableDecisions: testAllPermissionDecisions()}
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 2, false, "", 60) // cursor on future approval
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 2, 60) // cursor on future approval
|
||||
if len(offsets) != len(permissionOptions(request)) {
|
||||
t.Fatalf("offsets = %d, want %d", len(offsets), len(permissionOptions(request)))
|
||||
}
|
||||
@@ -368,7 +298,7 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) {
|
||||
Scope: "example.com",
|
||||
AvailableDecisions: testAllPermissionDecisions(),
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 72)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, 72)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"target: example.com", "allow this host for this conversation", "[s]", "allow this host in the future", "[y]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -446,171 +376,3 @@ func TestShiftDownComposerGuard(t *testing.T) {
|
||||
t.Fatalf("Shift+Down with non-empty composer scrolled offset to %d, want 3 (unchanged)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The highlighted permission option must use the selected-row tint, not the
|
||||
// brand chip. zeroTheme.badge is the accent-filled chip for short labels
|
||||
// (" 0 ", " ASK ", " SPEC REVIEW "); using it for a whole row painted a
|
||||
// full-brightness accent slab across a card whose palette is deliberately amber
|
||||
// (warning), and skipped the card tint every other line composes onto. selBg is
|
||||
// the tint tuned for a highlighted row against a panel, and onSel is what every
|
||||
// other selectable list in the TUI uses.
|
||||
func TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip(t *testing.T) {
|
||||
request := agent.PermissionRequest{ToolName: "exec_command", SideEffect: "shell"}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 70)
|
||||
|
||||
var selected string
|
||||
for _, line := range strings.Split(card, "\n") {
|
||||
if strings.Contains(ansiPattern.ReplaceAllString(line, ""), "▸ ") {
|
||||
selected = line
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == "" {
|
||||
t.Fatal("no highlighted option row rendered")
|
||||
}
|
||||
|
||||
accentBg := backgroundCode(darkPalette.accent)
|
||||
selBg := backgroundCode(darkPalette.selBg)
|
||||
if strings.Contains(selected, accentBg) {
|
||||
t.Errorf("selected row is filled with the brand accent %s (zeroTheme.badge); want the selection tint:\n%q", darkPalette.accent, selected)
|
||||
}
|
||||
if !strings.Contains(selected, selBg) {
|
||||
t.Errorf("selected row should carry the selection tint %s:\n%q", darkPalette.selBg, selected)
|
||||
}
|
||||
|
||||
// The PERMISSION chip keeps its amber fill — the card must still read as a
|
||||
// warning surface, so this fix must not flatten it.
|
||||
if !strings.Contains(card, backgroundCode(darkPalette.amber)) {
|
||||
t.Errorf("PERMISSION badge lost its amber fill:\n%q", card)
|
||||
}
|
||||
|
||||
// The card BODY carries no warm permBg wash any more: it matches the other
|
||||
// prompt cards (ask_user, spec) whose bodies are transparent. Warning identity
|
||||
// comes from the amber badge + border, not a full-body tint that clashes on
|
||||
// cool themes.
|
||||
if strings.Contains(card, backgroundCode(darkPalette.permBg)) {
|
||||
t.Errorf("permission card body still tinted with permBg %s; want a transparent body:\n%q", darkPalette.permBg, card)
|
||||
}
|
||||
}
|
||||
|
||||
// backgroundCode renders the SGR truecolor background sequence for a #rrggbb
|
||||
// palette entry, so assertions compare against the palette rather than
|
||||
// hardcoded numbers that drift when a theme is retuned.
|
||||
func backgroundCode(hex string) string {
|
||||
var r, g, b int
|
||||
fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
|
||||
return fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
|
||||
}
|
||||
|
||||
// A focused permission prompt suppresses the composer: keys drive the card, so a
|
||||
// "describe a task for zero…" box below it is inert and misleading — and once the
|
||||
// feedback field is open, the shared input must not echo in both places.
|
||||
func TestComposerSuppressedDuringPermissionPrompt(t *testing.T) {
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
|
||||
m.width, m.height = 96, 30
|
||||
|
||||
view := plainRender(t, m.View())
|
||||
if strings.Contains(view, "describe a task for zero") {
|
||||
t.Errorf("composer must be hidden while a permission prompt is focused:\n%s", view)
|
||||
}
|
||||
|
||||
// While typing feedback, the text lives on the card, not in a second box.
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
typed := typeRunes(t, next.(model), "use apply_patch")
|
||||
tv := plainRender(t, typed.View())
|
||||
if strings.Contains(tv, "describe a task for zero") {
|
||||
t.Errorf("composer must stay hidden in feedback mode:\n%s", tv)
|
||||
}
|
||||
if strings.Count(tv, "use apply_patch") != 1 {
|
||||
t.Errorf("feedback text should appear exactly once (on the card), got %d occurrences:\n%s", strings.Count(tv, "use apply_patch"), tv)
|
||||
}
|
||||
}
|
||||
|
||||
// Entering feedback mode must not lose a composer draft (a half-typed or queued
|
||||
// next-turn message). The draft is restored whether feedback is submitted or
|
||||
// cancelled; only the feedback text (delivered via the decision Reason) is
|
||||
// consumed.
|
||||
func TestPermissionFeedbackPreservesComposerDraftOnSubmit(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
})
|
||||
m.input.SetValue("my queued next message")
|
||||
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
nm := next.(model)
|
||||
if nm.input.Value() != "" {
|
||||
t.Fatalf("feedback field should start empty, got %q", nm.input.Value())
|
||||
}
|
||||
nm = typeRunes(t, nm, "use apply_patch")
|
||||
after, _ := nm.Update(testKey(tea.KeyEnter))
|
||||
am := after.(model)
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected exactly one permission decision, got %#v", got)
|
||||
}
|
||||
if got[0].Reason != "use apply_patch" {
|
||||
t.Fatalf("feedback text lost: %q", got[0].Reason)
|
||||
}
|
||||
if am.input.Value() != "my queued next message" {
|
||||
t.Fatalf("composer draft not restored after submit, got %q", am.input.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionFeedbackPreservesComposerDraftOnCancel(t *testing.T) {
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
|
||||
m.input.SetValue("my queued next message")
|
||||
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
nm := typeRunes(t, next.(model), "half a thought")
|
||||
after, _ := nm.Update(testKey(tea.KeyEsc))
|
||||
am := after.(model)
|
||||
|
||||
if am.pendingPermission == nil || am.pendingPermission.typing {
|
||||
t.Fatal("Esc should return to the option list")
|
||||
}
|
||||
if am.input.Value() != "my queued next message" {
|
||||
t.Fatalf("composer draft not restored after cancel, got %q", am.input.Value())
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace on an empty permission feedback field must edit the feedback text,
|
||||
// never drop a staged attachment. The keyBackspace case runs before the typing
|
||||
// branch and, on an empty composer, would otherwise remove the last staged
|
||||
// image/doc — which savedDraft does not restore.
|
||||
func TestPermissionFeedbackBackspaceKeepsStagedAttachment(t *testing.T) {
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
|
||||
m.pendingImages = []zeroruntime.ImageBlock{{MediaType: "image/png"}}
|
||||
m.pendingImageLabels = []string{"diagram.png"}
|
||||
|
||||
next, _ := m.Update(testKeyText("n")) // open feedback field (clears composer text)
|
||||
after, _ := next.(model).Update(testKey(tea.KeyBackspace))
|
||||
am := after.(model)
|
||||
|
||||
if len(am.pendingImageLabels) != 1 || len(am.pendingImages) != 1 {
|
||||
t.Fatalf("Backspace in feedback mode dropped the staged attachment: labels=%v imgs=%d", am.pendingImageLabels, len(am.pendingImages))
|
||||
}
|
||||
if !am.pendingPermission.typing {
|
||||
t.Fatal("Backspace should stay in the feedback field, not exit it")
|
||||
}
|
||||
}
|
||||
|
||||
// No permission option row is clickable while the feedback field is open: the
|
||||
// renderer registers zero clickable offsets in typing mode, so a stray click
|
||||
// (Allow included) has no row to land on. This is the primary safety; the
|
||||
// !typing guard on the click resolver in handleTranscriptSelectionMouse is the
|
||||
// explicit second layer for if this early-return is ever refactored away.
|
||||
func TestPermissionFeedbackRendersNoClickableOptionsWhileTyping(t *testing.T) {
|
||||
request := feedbackRequest()
|
||||
|
||||
// Option mode: rows are clickable.
|
||||
_, optOffsets := renderFocusedPermissionPrompt(request, 0, false, "", 80)
|
||||
if len(optOffsets) == 0 {
|
||||
t.Fatal("precondition: option rows should be clickable in option mode")
|
||||
}
|
||||
// Feedback mode: no clickable rows.
|
||||
_, typingOffsets := renderFocusedPermissionPrompt(request, 0, true, "some feedback", 80)
|
||||
if typingOffsets != nil {
|
||||
t.Fatalf("feedback mode must register no clickable option offsets, got %#v", typingOffsets)
|
||||
}
|
||||
}
|
||||
@@ -347,42 +347,22 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) {
|
||||
wizard.manageStatus = "No user config path — cannot delete."
|
||||
return m, nil
|
||||
}
|
||||
|
||||
persisted, err := config.ProviderPersisted(m.userConfigPath, name)
|
||||
cfg, err := config.RemoveProvider(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 activeAfter != "" && !strings.EqualFold(activeAfter, name) {
|
||||
notes = append(notes, "Active provider: "+activeAfter+".")
|
||||
} else if active := strings.TrimSpace(cfg.ActiveProvider); active != "" && !strings.EqualFold(active, name) {
|
||||
notes = append(notes, "Active provider: "+active+".")
|
||||
}
|
||||
cleanup := providerManagerCleanupCmd(m.userConfigPath, row.profile)
|
||||
|
||||
if len(m.savedProviders) == 0 {
|
||||
m.providerWizard = nil
|
||||
@@ -590,15 +570,6 @@ 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"
|
||||
|
||||
@@ -1959,49 +1959,12 @@ func (wizard *providerWizardState) filteredModels() []providerWizardModel {
|
||||
return models
|
||||
}
|
||||
|
||||
// A model Description is only a good row label when it reads like a display
|
||||
// NAME — "Grok 4.3" for x-ai/grok-4.3 — which is what the field is for, and why
|
||||
// the row prefers it over an ugly raw ID.
|
||||
//
|
||||
// Providers do not all honour that. Aggregators commonly return a prose blurb
|
||||
// instead ("Fast DeepSeek model for efficient chat, coding help, and agent
|
||||
// loops"), which fails as a label twice over: it overflows the row, and because
|
||||
// such blurbs are written per model FAMILY rather than per model, several
|
||||
// DIFFERENT models collapse to byte-identical rows. A picker then offers two
|
||||
// visually identical entries with the distinguishing ID visible only in the
|
||||
// detail line of whichever one is highlighted.
|
||||
//
|
||||
// providerWizardGenericModelDescription cannot catch these: it rejects
|
||||
// descriptions that are vague ("live model", "catalog default"), whereas these
|
||||
// are specific — just shared and sentence-shaped. So the test is shape, not
|
||||
// wording: a name is short and few-worded; anything longer is prose, and the ID
|
||||
// (unique by construction) makes the better label.
|
||||
const (
|
||||
providerWizardMaxModelNameChars = 28
|
||||
providerWizardMaxModelNameWords = 4
|
||||
)
|
||||
|
||||
func providerWizardDescriptionIsDisplayName(description string) bool {
|
||||
trimmed := strings.TrimSpace(description)
|
||||
if trimmed == "" || providerWizardGenericModelDescription(trimmed) {
|
||||
return false
|
||||
}
|
||||
if len(trimmed) > providerWizardMaxModelNameChars {
|
||||
return false
|
||||
}
|
||||
return len(strings.Fields(trimmed)) <= providerWizardMaxModelNameWords
|
||||
}
|
||||
|
||||
func (model providerWizardModel) displayLabel() string {
|
||||
if providerWizardDescriptionIsDisplayName(model.Description) {
|
||||
return strings.TrimSpace(model.Description)
|
||||
description := strings.TrimSpace(model.Description)
|
||||
if description != "" && !providerWizardGenericModelDescription(description) {
|
||||
return description
|
||||
}
|
||||
if id := strings.TrimSpace(model.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
// ID-less sentinel rows built in currentModel ("model name required",
|
||||
// "no matching models") would otherwise render blank.
|
||||
return strings.TrimSpace(model.Description)
|
||||
return model.ID
|
||||
}
|
||||
|
||||
func (model providerWizardModel) secondaryText() string {
|
||||
|
||||
@@ -1787,37 +1787,3 @@ func providerWizardIDs(descriptors []providercatalog.Descriptor) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// A provider that returns prose blurbs instead of display names must not
|
||||
// collapse distinct models into identical rows. Reproduces the reported picker
|
||||
// where two "Open-weight GPT model..." and two "Kimi multimodal agent model..."
|
||||
// rows were indistinguishable, with the ID visible only for the highlighted one.
|
||||
func TestProviderWizardModelRowsStayDistinctWithProseDescriptions(t *testing.T) {
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepModel,
|
||||
modelSource: "models.dev",
|
||||
models: []providerWizardModel{
|
||||
{ID: "gpt-oss-120b", Description: "Open-weight GPT model for self-hosted reasoning and instruction-following workloads"},
|
||||
{ID: "gpt-oss-20b", Description: "Open-weight GPT model for self-hosted reasoning and instruction-following workloads"},
|
||||
{ID: "kimi-k2-thinking", Description: "Kimi multimodal agent model for visual understanding, coding, and planning"},
|
||||
{ID: "kimi-k2-instruct", Description: "Kimi multimodal agent model for visual understanding, coding, and planning"},
|
||||
{ID: "x-ai/grok-4.3", Description: "Grok 4.3"},
|
||||
},
|
||||
}
|
||||
view := plainRender(t, strings.Join(wizard.renderModelStep(96), "\n"))
|
||||
|
||||
// every prose-described model is identified by its unique ID
|
||||
for _, id := range []string{"gpt-oss-120b", "gpt-oss-20b", "kimi-k2-thinking", "kimi-k2-instruct"} {
|
||||
if !strings.Contains(view, id) {
|
||||
t.Errorf("model %q is not identifiable in the list:\n%s", id, view)
|
||||
}
|
||||
}
|
||||
// a genuine display name is still preferred over the raw id
|
||||
if !strings.Contains(view, "Grok 4.3") {
|
||||
t.Errorf("friendly display name was lost:\n%s", view)
|
||||
}
|
||||
// the prose blurb must not be a row label
|
||||
if strings.Contains(view, "❯ Open-weight GPT model") {
|
||||
t.Errorf("prose blurb still used as a row label:\n%s", view)
|
||||
}
|
||||
}
|
||||
@@ -66,11 +66,6 @@ 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{},
|
||||
@@ -1107,19 +1102,12 @@ func wrapDetailBlock(detail string, width int) string {
|
||||
// renderFocusedPermissionPrompt draws the modal permission card and reports the
|
||||
// card-relative Y offset of each option line (in permissionOptions order) so the
|
||||
// caller can register those lines as clickable.
|
||||
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, typing bool, feedback string, width int) (string, []int) {
|
||||
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, width int) (string, []int) {
|
||||
name := strings.TrimSpace(request.ToolName)
|
||||
if name == "" {
|
||||
name = "tool"
|
||||
}
|
||||
// The card body carries no background fill, matching every other prompt card
|
||||
// (ask_user, spec review, plan) — see the lipgloss.NewStyle() fills below. The
|
||||
// permission card used to tint its whole body with permBg, an amber-family
|
||||
// wash that reads as a warm slab on cool themes (e.g. a brown-yellow box over
|
||||
// dracula's purples) and made it the one outlier. The amber PERMISSION badge
|
||||
// and the amber-mixed border still carry the "this is a permission gate"
|
||||
// signal; the body no longer clashes with the surrounding theme.
|
||||
fill := func(style lipgloss.Style) lipgloss.Style { return style }
|
||||
fill := zeroTheme.onPerm
|
||||
|
||||
top := zeroTheme.permBadge.Render(" PERMISSION ")
|
||||
|
||||
@@ -1141,17 +1129,6 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
|
||||
|
||||
lines = append(lines, "")
|
||||
|
||||
// Feedback mode: the option list is replaced by a free-text field, like the
|
||||
// ask_user "type your own answer" surface. What is typed is sent to the model
|
||||
// as the denial reason, so it reads the instruction and adjusts.
|
||||
if typing {
|
||||
lines = append(lines, fill(zeroTheme.muted).Render("Tell Zero what to do differently:"))
|
||||
lines = append(lines, zeroTheme.userPrompt.Render("❯ ")+fill(zeroTheme.ink).Render(feedback)+fill(zeroTheme.accent).Render("▌"))
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fill(zeroTheme.faint).Render("enter · send to Zero esc · back to options"))
|
||||
return styledBlockFill(width, lines, zeroTheme.permBorder, zeroTheme.permBg), nil
|
||||
}
|
||||
|
||||
// Each option is its own line so a click anywhere on that row selects it (no
|
||||
// per-column hit-testing). The highlighted row gets a ▸ marker and a reverse
|
||||
// label; the rest stay quiet. styledBlockFill prepends exactly one top-border
|
||||
@@ -1165,16 +1142,8 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
|
||||
hotkey := fill(zeroTheme.faint).Render(" [" + option.hotkey + "]")
|
||||
optionLabel := permissionOptionLabel(option, request)
|
||||
if index == cursor {
|
||||
// onSel, not badge. zeroTheme.badge is the brand chip (" 0 ", " ASK ",
|
||||
// " SPEC REVIEW ") — a full-brightness accent fill meant for short
|
||||
// labels. Using it for a selected ROW painted a bright accent slab
|
||||
// across the permission card, fighting the card's amber warning palette
|
||||
// and ignoring the card tint every other line composes onto. selBg is
|
||||
// the tint tuned for exactly this job ("separates from the panel while
|
||||
// ink label contrast stays ~9.4:1"), and onSel is what every other
|
||||
// selectable list in the TUI uses for its highlighted row.
|
||||
marker := fill(zeroTheme.accent).Render("▸ ")
|
||||
label := zeroTheme.onSel(zeroTheme.ink).Bold(true).Render(" " + optionLabel + " ")
|
||||
label := zeroTheme.badge.Render(" " + optionLabel + " ")
|
||||
lines = append(lines, marker+label+hotkey)
|
||||
} else {
|
||||
label := fill(zeroTheme.ink).Render(optionLabel)
|
||||
@@ -1189,7 +1158,7 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
|
||||
}
|
||||
lines = append(lines, fill(zeroTheme.faint).Render(footer))
|
||||
|
||||
return styledBlockFill(width, lines, zeroTheme.permBorder, lipgloss.NewStyle()), offsets
|
||||
return styledBlockFill(width, lines, zeroTheme.permBorder, zeroTheme.permBg), offsets
|
||||
}
|
||||
|
||||
func permissionScopeLine(request agent.PermissionRequest, scope string) string {
|
||||
|
||||
@@ -1659,7 +1659,7 @@ func TestFocusedPermissionCardShowsBadgeAndKeys(t *testing.T) {
|
||||
Risk: sandbox.Risk{Level: sandbox.RiskMedium},
|
||||
AvailableDecisions: testAllPermissionDecisions(),
|
||||
}
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 0, false, "", 80)
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 0, 80)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"PERMISSION", "edit_file", "writes internal/agent/exec.go", "Yes, proceed", "[a]", "this session", "[s]", "don't ask again", "[y]", "continue without running it", "[d]", "[esc]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
|
||||
@@ -111,12 +111,6 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,15 +246,11 @@ func (m model) sessionPrompt(prompt string) string {
|
||||
if m.activeSession.SessionID == "" || len(m.sessionEvents) == 0 {
|
||||
return prompt
|
||||
}
|
||||
formatted := sessions.FormatExecPrompt(prompt, sessions.PreparedExec{
|
||||
return sessions.FormatExecPrompt(prompt, sessions.PreparedExec{
|
||||
Mode: sessions.ModeResume,
|
||||
Session: m.activeSession,
|
||||
ContextEvents: append([]sessions.Event{}, m.sessionEvents...),
|
||||
})
|
||||
if m.activeSession.SessionKind == sessions.SessionKindSide {
|
||||
return btwContextBoundary + "\n\n" + formatted
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
func (m model) resolveResumeSession(args string) (*sessions.Metadata, error) {
|
||||
@@ -280,9 +276,6 @@ func (m model) resolveResumeSession(args string) (*sessions.Metadata, error) {
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("zero session not found: %s", args)
|
||||
}
|
||||
if !sessions.IsResumableKind(session.SessionKind) {
|
||||
return nil, fmt.Errorf("zero session is not resumable: %s", args)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -976,12 +976,6 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
+5
-19
@@ -94,26 +94,12 @@ func (m model) sidebarAvailable() bool {
|
||||
if widthTier(m.width) < tierMedium {
|
||||
return false
|
||||
}
|
||||
// Full-screen overlays (setup, wizards, pickers) take over the chat column and
|
||||
// render at full width; suppress the second column while any is active so
|
||||
// their geometry and mouse hit-testing stay full-width as before.
|
||||
//
|
||||
// The `/` command palette is deliberately NOT in this list. It is not a
|
||||
// full-screen overlay: suggestionOverlay draws it via centerRenderedBlock at
|
||||
// minInt(width, suggestionPaletteMaxWidth) — a centred box capped at 76 cells
|
||||
// — floating over the chat column rather than replacing it. Suppressing the
|
||||
// sidebar for it collapsed the whole layout on every keystroke of `/`: the
|
||||
// plan panel dropped out of the sidebar and re-rendered inline at the bottom
|
||||
// and the transcript re-wrapped to full width, which is at its most jarring
|
||||
// mid-run with a live plan on screen. Since the sidebar already requires the
|
||||
// medium tier (>= 80 cells) the palette is always strictly narrower than the
|
||||
// chat column here, so the two never contend for the same cells.
|
||||
//
|
||||
// Mouse hit-testing is unaffected: sidebarLineAtMouse carries its own
|
||||
// suggestionsActive() guard, so clicks still go to the palette and not to the
|
||||
// sidebar rows underneath it.
|
||||
// Full-screen overlays (setup, wizards, pickers, the empty-state suggestion
|
||||
// list) take over the chat column and render at full width; suppress the
|
||||
// second column while any is active so their geometry and mouse hit-testing
|
||||
// stay full-width as before.
|
||||
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil {
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return false
|
||||
}
|
||||
// Home/welcome screen: stay single-column until there's real conversation, so
|
||||
|
||||
@@ -631,58 +631,3 @@ func TestTwoColumnTranscriptViewWidth(t *testing.T) {
|
||||
func stripSidebar(lines []string) string {
|
||||
return ansiPattern.ReplaceAllString(strings.Join(lines, "\n"), "")
|
||||
}
|
||||
|
||||
// The `/` command palette must NOT collapse the sidebar. It is a centred box
|
||||
// capped at suggestionPaletteMaxWidth floating over the chat column, not a
|
||||
// full-screen overlay — suppressing the second column for it dropped the plan
|
||||
// out of the sidebar and re-rendered it inline at the bottom on every `/`,
|
||||
// which is at its most disruptive mid-run with a live plan on screen. The
|
||||
// genuinely full-width overlays must still suppress it.
|
||||
func TestSidebarSurvivesCommandPalette(t *testing.T) {
|
||||
base := func() model {
|
||||
m := runningPlanModel(t, 3)
|
||||
m.altScreen = true
|
||||
m.height = 40
|
||||
m.headerPrinted = true
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
||||
return m
|
||||
}
|
||||
|
||||
m := base()
|
||||
if !m.sidebarActive() {
|
||||
t.Fatal("precondition: sidebar should be active for a wide alt-screen model with a plan")
|
||||
}
|
||||
|
||||
// `/` palette open: sidebar stays, so the plan keeps its home and the layout
|
||||
// does not reflow.
|
||||
m.suggestions = []commandSuggestion{{Name: "/model", Desc: "Pick a model."}, {Name: "/plan", Desc: "Show planning mode status."}}
|
||||
if !m.suggestionsActive() {
|
||||
t.Fatal("precondition: suggestions should be active")
|
||||
}
|
||||
if !m.sidebarActive() {
|
||||
t.Error("command palette must not collapse the sidebar")
|
||||
}
|
||||
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
|
||||
t.Errorf("plan must stay in the sidebar, not fall back to the pinned panel:\n%s", got)
|
||||
}
|
||||
// The palette never contends for the sidebar's cells: the two-column path
|
||||
// renders it at width = chatColumnWidth, so it is centred inside the chat
|
||||
// column and cannot overlap the sidebar.
|
||||
chatW := m.chatColumnWidth()
|
||||
for _, line := range strings.Split(plainRender(t, m.suggestionOverlay(chatW)), "\n") {
|
||||
if w := lipgloss.Width(line); w > chatW {
|
||||
t.Errorf("palette line is %d wide, wider than the chat column %d — it would overlap the sidebar", w, chatW)
|
||||
}
|
||||
}
|
||||
// Clicks still belong to the palette, not the sidebar rows beneath it.
|
||||
if _, ok := m.sidebarLineAtMouse(tea.MouseClickMsg{Button: tea.MouseLeft}); ok {
|
||||
t.Error("sidebar must not take mouse hits while the palette is open")
|
||||
}
|
||||
|
||||
// A genuinely full-width overlay still suppresses the sidebar.
|
||||
full := base()
|
||||
full.picker = &commandPicker{}
|
||||
if full.sidebarActive() {
|
||||
t.Error("a full-screen picker must still collapse the sidebar")
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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,7 +4,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -39,29 +38,6 @@ 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
|
||||
@@ -191,9 +167,6 @@ 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 {
|
||||
@@ -238,21 +211,6 @@ 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.
|
||||
@@ -281,6 +239,7 @@ func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed
|
||||
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.
|
||||
@@ -288,32 +247,11 @@ func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed
|
||||
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)
|
||||
}
|
||||
if startIdx > 0 {
|
||||
previousKind, havePreviousKind = m.flushedPreviousKind, m.flushedHavePreviousKind
|
||||
} else {
|
||||
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc)
|
||||
}
|
||||
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc)
|
||||
specialistSummaryEmitted := false
|
||||
for index := startIdx; index < len(m.transcript); index++ {
|
||||
row := m.transcript[index]
|
||||
@@ -454,7 +392,7 @@ func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed
|
||||
rowIndex: -1,
|
||||
heightCacheStable: false, // the highlight changes with the cursor
|
||||
render: func(startBodyY int) transcriptBodyRenderedItem {
|
||||
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, perm.typing, m.input.Value(), width)
|
||||
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, width)
|
||||
options := permissionOptions(perm.request)
|
||||
selectable := make([]transcriptSelectableLine, 0, len(offsets))
|
||||
for index, offset := range offsets {
|
||||
@@ -1388,7 +1326,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.setSelectedFile(path)
|
||||
m.selectedFile = path
|
||||
return m.openFileView(path), nil, true
|
||||
}
|
||||
return m.selectFile(path), nil, true
|
||||
@@ -1401,14 +1339,8 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
}
|
||||
return m, nil, false
|
||||
}
|
||||
if line.permOption && !(m.pendingPermission != nil && m.pendingPermission.typing) {
|
||||
// A left-click on a permission-popup option resolves it directly. The
|
||||
// typing guard is defence-in-depth: renderFocusedPermissionPrompt already
|
||||
// returns nil offsets in feedback mode, so no option row is registered as
|
||||
// clickable then — but that single early-return is the only thing keeping
|
||||
// a stray click (Allow included) off the decision path, and it lives in a
|
||||
// function other PRs also edit. Guarding here makes the safety explicit
|
||||
// rather than emergent.
|
||||
if line.permOption {
|
||||
// A left-click on a permission-popup option resolves it directly.
|
||||
next, cmd := m.resolvePermission(line.permChoice)
|
||||
return next.(model), cmd, true
|
||||
}
|
||||
@@ -1510,11 +1442,6 @@ 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
|
||||
}
|
||||
|
||||
+8
-12
@@ -195,21 +195,17 @@ func (m model) statusLine(width int) string {
|
||||
// in its mode colour. This was previously only on the easy-to-miss composer
|
||||
// rule; the persistent footer is where users look for "will it run commands?".
|
||||
modeText, modeStyle := m.modeLabel()
|
||||
btwChip := ""
|
||||
if m.btw.active {
|
||||
btwChip = zeroTheme.amber.Render("BTW") + zeroTheme.muted.Render(" · ")
|
||||
}
|
||||
left := prefix + btwChip + zeroTheme.accent.Render("●") + " " + modeStyle.Render(modeText)
|
||||
left := prefix + zeroTheme.accent.Render("●") + " " + modeStyle.Render(modeText)
|
||||
|
||||
if tier == tierTiny {
|
||||
if m.exitConfirmActive {
|
||||
return fitStyledLine(prefix+btwChip+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
|
||||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
|
||||
}
|
||||
if m.cancelConfirmActive {
|
||||
return fitStyledLine(prefix+btwChip+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
|
||||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
|
||||
}
|
||||
if dictation := m.dictationStatusChip(); dictation != "" {
|
||||
return fitStyledLine(prefix+btwChip+dictation, width)
|
||||
return fitStyledLine(prefix+dictation, width)
|
||||
}
|
||||
return fitStyledLine(left, width)
|
||||
}
|
||||
@@ -219,16 +215,16 @@ func (m model) statusLine(width int) string {
|
||||
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort))
|
||||
}
|
||||
if m.exitConfirmActive {
|
||||
left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
|
||||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
|
||||
} else if m.cancelConfirmActive {
|
||||
left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
|
||||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
|
||||
} else if m.dictation.downloading && m.dictation.downloadStatus != "" {
|
||||
// A model download in progress takes over the left chip with a live percentage.
|
||||
left = prefix + btwChip + zeroTheme.accent.Render("⬇ ") + zeroTheme.muted.Render(m.dictation.downloadStatus)
|
||||
left = prefix + zeroTheme.accent.Render("⬇ ") + zeroTheme.muted.Render(m.dictation.downloadStatus)
|
||||
} else if dictation := m.dictationStatusChip(); dictation != "" && m.dictation.active() {
|
||||
// An active recording/transcription takes over the left chip — it is the
|
||||
// most time-sensitive thing on screen (the mic is live).
|
||||
left = prefix + btwChip + dictation
|
||||
left = prefix + dictation
|
||||
} else {
|
||||
if voice := m.voiceModeIndicator(); voice != "" {
|
||||
left += zeroTheme.muted.Render(" · ") + voice
|
||||
|
||||
Reference in new issue
Block a user