mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e250c7b1 | ||
|
|
3c82c20d20 | ||
|
|
f7c74ef784 | ||
|
|
ae4ce72815 | ||
|
|
99cb28d1a3 | ||
|
|
410173c701 | ||
|
|
71da864a74 | ||
|
|
c8fa54c675 | ||
|
|
c3ece4b198 | ||
|
|
4960e40f1b | ||
|
|
12add88691 | ||
|
|
25ae21952d | ||
|
|
0d35d568f2 | ||
|
|
d967b26817 | ||
|
|
980f7d7f41 | ||
|
|
4721ce727c | ||
|
|
fdf11d6a0a | ||
|
|
d8cf8d3923 | ||
|
|
8774620e09 | ||
|
|
44998d4745 | ||
|
|
3a453baa7c | ||
|
|
ad34419ee5 | ||
|
|
9afcd9651c | ||
|
|
f9416f3283 | ||
|
|
624d7f798b | ||
|
|
adeda876b7 | ||
|
|
a1feeb37e2 | ||
|
|
b13529cb14 | ||
|
|
2c227a3395 | ||
|
|
4c7c3a1f48 | ||
|
|
2f525c2f5f |
No files matched your search
@@ -253,7 +253,6 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *t
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__gamma", desc: "gamma"})
|
||||
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
|
||||
|
||||
// 3 deferred, disable beta => 2 surviving eligible, threshold 2 => active.
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
|
||||
@@ -322,21 +321,13 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
|
||||
|
||||
loaded := map[string]bool{"mcp__srv__alpha": true, "mcp__srv__beta": true}
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
|
||||
exposedNames := map[string]bool{}
|
||||
for _, def := range exposed {
|
||||
exposedNames[def.Name] = true
|
||||
}
|
||||
if !exposedNames["mcp__srv__alpha"] || !exposedNames["mcp__srv__beta"] {
|
||||
if len(exposed) != 2 {
|
||||
t.Fatalf("expected both loaded deferred tools exposed, got %#v", exposed)
|
||||
}
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
// BuildDeferredReminder returns "" for no hidden lines.
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty reminder when nothing is hidden, got %q", reminder)
|
||||
@@ -349,9 +340,6 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||
// A real, usable tool_search must be registered for deferral to activate
|
||||
// (otherwise the loop falls back to eager so it never strands the loader).
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{ // turn 1: call load_signal
|
||||
@@ -420,9 +408,6 @@ func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||
// A real, usable tool_search must be registered for deferral to activate
|
||||
// (otherwise the loop falls back to eager so it never strands the loader).
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
|
||||
// Request indices (mockProvider plays one turn per request, in order):
|
||||
// 0: turn 1 — calls load_signal (loads mcp__srv__alpha for later turns)
|
||||
@@ -557,9 +542,6 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testin
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||
// A real, usable tool_search must be registered for deferral to activate
|
||||
// (otherwise the loop falls back to eager so it never strands the loader).
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
|
||||
// Request indices:
|
||||
// 0: turn 1 — calls load_signal (loads mcp__srv__alpha for later turns)
|
||||
@@ -719,7 +701,7 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
// DisabledTools entry for tool_search is STILL honored on the active path: the
|
||||
// loader is not exposed and a call to it is rejected (FIX 2 exempts the allowlist
|
||||
// only, never the denylist).
|
||||
func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
|
||||
func TestDisabledToolSearchStaysHiddenAndRejectedWhenActive(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
@@ -730,39 +712,24 @@ func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
|
||||
DisabledTools: []string{"tool_search"},
|
||||
}
|
||||
|
||||
// tool_search is explicitly disabled, so it can never run. Deferral must NOT
|
||||
// activate — otherwise the loop would hide the deferred tools behind a loader
|
||||
// the dispatch gate rejects (an inescapable dead-end). Expect the eager /
|
||||
// inactive fallback: every deferred tool exposed with its full schema, no
|
||||
// tool_search definition, and an empty reminder.
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
if reminder != "" {
|
||||
t.Fatalf("inactive fallback must emit no reminder, got %q", reminder)
|
||||
}
|
||||
exposedNames := make(map[string]bool, len(exposed))
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
for _, def := range exposed {
|
||||
exposedNames[def.Name] = true
|
||||
}
|
||||
if exposedNames["tool_search"] {
|
||||
t.Fatalf("tool_search must not be advertised when disabled, got %#v", exposed)
|
||||
}
|
||||
if !exposedNames["mcp__srv__alpha"] || !exposedNames["mcp__srv__beta"] {
|
||||
t.Fatalf("deferred tools must be exposed eagerly when deferral is inactive, got %#v", exposed)
|
||||
if def.Name == "tool_search" {
|
||||
t.Fatalf("tool_search must stay hidden when explicitly disabled, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
|
||||
// The deferred tools themselves stay directly callable (not stranded behind a
|
||||
// disabled loader).
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "mcp__srv__alpha", Arguments: `{}`},
|
||||
ToolCall{ID: "c1", Name: "tool_search", Arguments: `{"query":"select:mcp__srv__alpha"}`},
|
||||
PermissionModeAuto,
|
||||
options,
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatalf("unexpected abort error: %v", abortErr)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
|
||||
if result.Status != tools.StatusError || result.DenialReason != DenialFiltered {
|
||||
t.Fatalf("a disabled tool_search call must be filtered out, got status=%s denial=%v output=%q", result.Status, result.DenialReason, result.Output)
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
|
||||
// assignable field on Options with the agreed signature, and that nil is its
|
||||
// zero value (escalation disabled by default).
|
||||
func TestOptionsModelSwitcherFieldExists(t *testing.T) {
|
||||
var options Options
|
||||
if options.ModelSwitcher != nil {
|
||||
t.Fatalf("expected ModelSwitcher to default to nil, got non-nil")
|
||||
}
|
||||
options.ModelSwitcher = func(_ context.Context, modelID string) (Provider, error) {
|
||||
return &mockProvider{}, nil
|
||||
}
|
||||
provider, err := options.ModelSwitcher(context.Background(), "claude-sonnet-4.5")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := provider.(*mockProvider); !ok {
|
||||
t.Fatalf("expected ModelSwitcher to return a Provider, got %T", provider)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResultRequestedModelFieldExists asserts the loop-level escalation
|
||||
// signal field is present on ToolResult and empty for a normal result.
|
||||
func TestToolResultRequestedModelFieldExists(t *testing.T) {
|
||||
var result ToolResult
|
||||
if result.RequestedModel != "" {
|
||||
t.Fatalf("expected RequestedModel to default to empty, got %q", result.RequestedModel)
|
||||
}
|
||||
result.RequestedModel = "claude-opus-4.1"
|
||||
if result.RequestedModel != "claude-opus-4.1" {
|
||||
t.Fatalf("expected RequestedModel to round-trip, got %q", result.RequestedModel)
|
||||
}
|
||||
// Keep the zeroruntime import load-bearing so the file compiles standalone.
|
||||
_ = zeroruntime.MessageRoleAssistant
|
||||
}
|
||||
|
||||
// escalatingTool is a registered fake tool that returns the escalation signal
|
||||
// in result Meta (mirroring the real escalate_model tool's contract), used to
|
||||
// drive the loop-level switch in tests without depending on the tools package.
|
||||
type escalatingTool struct {
|
||||
target string
|
||||
}
|
||||
|
||||
func (t escalatingTool) Name() string { return "escalate" }
|
||||
func (t escalatingTool) Description() string { return "requests a model switch for testing" }
|
||||
func (t escalatingTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (t escalatingTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t escalatingTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
meta := map[string]string{}
|
||||
if t.target != "" {
|
||||
meta["escalate_to_model"] = t.target
|
||||
}
|
||||
return tools.Result{Status: tools.StatusOK, Output: "escalating", Meta: meta}
|
||||
}
|
||||
|
||||
// TestExecuteToolCallLiftsEscalationMeta verifies executeToolCall promotes the
|
||||
// tool's Meta["escalate_to_model"] into ToolResult.RequestedModel.
|
||||
func TestExecuteToolCallLiftsEscalationMeta(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "escalate"},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatal(abortErr)
|
||||
}
|
||||
if result.RequestedModel != "claude-opus-4.1" {
|
||||
t.Fatalf("expected RequestedModel lifted from meta, got %q", result.RequestedModel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteToolCallNoEscalationMetaLeavesRequestedModelEmpty verifies a normal
|
||||
// tool result (no escalate_to_model meta) leaves RequestedModel empty.
|
||||
func TestExecuteToolCallNoEscalationMetaLeavesRequestedModelEmpty(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: ""})
|
||||
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "escalate"},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatal(abortErr)
|
||||
}
|
||||
if result.RequestedModel != "" {
|
||||
t.Fatalf("expected empty RequestedModel without escalation meta, got %q", result.RequestedModel)
|
||||
}
|
||||
}
|
||||
|
||||
// escalateThenAnswerTurns builds a two-turn provider script: turn 1 calls the
|
||||
// escalate tool, turn 2 returns a final answer. Reused by the switch tests.
|
||||
func escalateThenAnswerTurns(answer string) [][]zeroruntime.StreamEvent {
|
||||
return [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "escalate"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: answer},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSwitchesProviderOnEscalationRequest verifies that when a tool requests
|
||||
// a model and a ModelSwitcher is wired, the loop swaps the active provider (the
|
||||
// NEXT turn streams from the new provider) and updates options.Model.
|
||||
func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
firstProvider := &mockProvider{turns: escalateThenAnswerTurns("done")}
|
||||
secondProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "answered on the upgraded model"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
var switchedTo string
|
||||
switchCount := 0
|
||||
result, err := Run(context.Background(), "go", firstProvider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
||||
switchCount++
|
||||
switchedTo = modelID
|
||||
return secondProvider, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if switchCount != 1 {
|
||||
t.Fatalf("expected exactly one switch, got %d", switchCount)
|
||||
}
|
||||
if switchedTo != "claude-opus-4.1" {
|
||||
t.Fatalf("expected switch to claude-opus-4.1, got %q", switchedTo)
|
||||
}
|
||||
// The first provider only handled the escalation turn (1 request); the second
|
||||
// provider handled the answer turn (1 request) — proving the swap took effect.
|
||||
if len(firstProvider.requests) != 1 {
|
||||
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
|
||||
}
|
||||
if len(secondProvider.requests) != 1 {
|
||||
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
|
||||
}
|
||||
if result.FinalAnswer != "answered on the upgraded model" {
|
||||
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunIgnoresEscalationWhenSwitcherNil verifies that without a ModelSwitcher
|
||||
// the escalation request is ignored: the original provider serves every turn.
|
||||
func TestRunIgnoresEscalationWhenSwitcherNil(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
provider := &mockProvider{turns: escalateThenAnswerTurns("done on original")}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
// ModelSwitcher intentionally nil.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both turns (escalate + answer) must run on the single original provider.
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the original provider to serve both turns, got %d requests", len(provider.requests))
|
||||
}
|
||||
if result.FinalAnswer != "done on original" {
|
||||
t.Fatalf("expected final answer from the original provider, got %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunEscalationSwitcherErrorIsNonFatal verifies a ModelSwitcher error does
|
||||
// not abort the run: the loop continues on the current provider and a note is
|
||||
// recorded in the transcript.
|
||||
func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||
return nil, errors.New("provider build blew up")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected escalation error to be non-fatal, got %v", err)
|
||||
}
|
||||
// The run continues on the same provider through the answer turn.
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
|
||||
}
|
||||
if result.FinalAnswer != "recovered" {
|
||||
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
||||
}
|
||||
// A brief note about the failed switch must reach the model on the next turn.
|
||||
var sawNote bool
|
||||
for _, m := range provider.requests[1].Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(m.Content), "could not switch") {
|
||||
sawNote = true
|
||||
}
|
||||
}
|
||||
if !sawNote {
|
||||
t.Fatalf("expected a non-fatal switch-failure note on the next turn, messages: %+v", provider.requests[1].Messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunEscalationSwitcherNilProviderKeepsOriginal verifies that when a
|
||||
// ModelSwitcher returns (nil, nil) — no error, but also no replacement provider —
|
||||
// the loop does NOT null out the active provider. The run must continue on the
|
||||
// ORIGINAL provider and return a result rather than panic on a nil provider.
|
||||
func TestRunEscalationSwitcherNilProviderKeepsOriginal(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
provider := &mockProvider{turns: escalateThenAnswerTurns("answered on original")}
|
||||
|
||||
switchCount := 0
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||
switchCount++
|
||||
// No error, but no provider either: the loop must NOT swap to nil.
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected (nil,nil) switch to be non-fatal, got %v", err)
|
||||
}
|
||||
if switchCount != 1 {
|
||||
t.Fatalf("expected exactly one switch attempt, got %d", switchCount)
|
||||
}
|
||||
// Both turns (escalate + answer) must run on the single original provider; if
|
||||
// the loop nil'd out the provider on a (nil,nil) return, the second turn would
|
||||
// panic or never run.
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the original provider to serve both turns, got %d requests", len(provider.requests))
|
||||
}
|
||||
if result.FinalAnswer != "answered on original" {
|
||||
t.Fatalf("expected final answer from the original provider, got %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSwitchesAtMostOncePerTurn verifies that when a single turn carries two
|
||||
// escalation requests, only the first triggers a switch.
|
||||
func TestRunSwitchesAtMostOncePerTurn(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
firstProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "escalate"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "escalate"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
secondProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
switchCount := 0
|
||||
if _, err := Run(context.Background(), "go", firstProvider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||
switchCount++
|
||||
return secondProvider, nil
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if switchCount != 1 {
|
||||
t.Fatalf("expected at most one switch per turn, got %d", switchCount)
|
||||
}
|
||||
}
|
||||
+23
-70
@@ -33,12 +33,6 @@ const abortedToolResultNotice = "aborted: run halted by the repeated-failure gua
|
||||
const droppedToolCallNotice = "Your previous tool call was malformed (it was missing a tool name) and was not executed. " +
|
||||
"Re-issue the tool call with a valid tool name and JSON arguments, or reply with your final answer."
|
||||
|
||||
// escalationFailedNoticePrefix introduces the brief, user-role note recorded
|
||||
// when a requested mid-run model switch could not be performed (the
|
||||
// ModelSwitcher returned an error). The run continues on the current model;
|
||||
// escalation is best-effort, never fatal.
|
||||
const escalationFailedNoticePrefix = "Note: could not switch to the requested model"
|
||||
|
||||
// The system prompt (core coding-craft instructions + workspace context + safety
|
||||
// confirmation policy) is assembled in system_prompt.go via buildSystemPrompt.
|
||||
|
||||
@@ -241,11 +235,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
guards.observeTurn(collected)
|
||||
|
||||
failureHint := ""
|
||||
// turnRequestedModel records the FIRST mid-run escalation target requested
|
||||
// during this turn's tool batch. The actual provider switch happens once,
|
||||
// after the batch, so every tool_result is recorded first and at most one
|
||||
// switch occurs per turn.
|
||||
turnRequestedModel := ""
|
||||
for index, call := range collected.ToolCalls {
|
||||
if options.OnToolCall != nil {
|
||||
options.OnToolCall(call)
|
||||
@@ -260,9 +249,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
for _, name := range toolResult.LoadedTools {
|
||||
loaded[name] = true
|
||||
}
|
||||
if turnRequestedModel == "" && toolResult.RequestedModel != "" {
|
||||
turnRequestedModel = toolResult.RequestedModel
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.Output,
|
||||
@@ -314,32 +300,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
// Mid-run model escalation: if a tool asked to switch models this turn and
|
||||
// a switcher is wired, rebuild the provider on the new model for the rest
|
||||
// of the run. At most one switch per turn (first request wins). A switcher
|
||||
// error is NON-FATAL — record a brief note and continue on the current
|
||||
// model. nil switcher ⇒ requests are ignored entirely (escalation off).
|
||||
if turnRequestedModel != "" && options.ModelSwitcher != nil {
|
||||
newProvider, switchErr := options.ModelSwitcher(ctx, turnRequestedModel)
|
||||
if switchErr != nil {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: escalationFailedNoticePrefix + " (" + turnRequestedModel + "): " + switchErr.Error() + ". Continuing on " + options.Model + ".",
|
||||
})
|
||||
} else if newProvider != nil {
|
||||
// Reassign the local provider so the next turn's StreamCompletion and
|
||||
// compaction use it; update options.Model so subsequent RunOptions.Model,
|
||||
// context-window sizing, and usage attribution follow the new model.
|
||||
provider = newProvider
|
||||
options.Model = turnRequestedModel
|
||||
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
||||
// is fixed at run start from options.ContextWindow and is NOT updated
|
||||
// here, so a switch to a model with a different window keeps compacting
|
||||
// against the original budget. Fixing it needs a ModelSwitcher contract
|
||||
// change (return the new window) — out of scope for this change.
|
||||
}
|
||||
}
|
||||
|
||||
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
|
||||
// calls executed above; surface the dropped call too so it is never
|
||||
// silently ignored just because the turn also did real work. This is
|
||||
@@ -516,11 +476,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: result.Display,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
// A tool may signal a mid-run model escalation by carrying the target id
|
||||
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
|
||||
// the Run turn loop performs the actual provider switch. Empty for every
|
||||
// ordinary tool result.
|
||||
RequestedModel: result.Meta["escalate_to_model"],
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -903,6 +858,15 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
|
||||
}
|
||||
}
|
||||
|
||||
// toolDefinitions returns the full-schema tool list for a turn. It now delegates
|
||||
// to partitionTools with no loaded set; with deferral inactive (the only state
|
||||
// the bare toolDefinitions is used in) the exposed slice is byte-identical to the
|
||||
// pre-deferral output. Retained so existing callers/tests keep their signature.
|
||||
func toolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
|
||||
exposed, _ := partitionTools(registry, permissionMode, options, map[string]bool{})
|
||||
return exposed
|
||||
}
|
||||
|
||||
// partitionTools builds the per-turn advertised tool list and an optional
|
||||
// deferred-tools reminder. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
||||
@@ -927,20 +891,7 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
}
|
||||
}
|
||||
|
||||
// Deferral may activate only when tool_search is actually runnable; otherwise
|
||||
// the loop would hide deferred tools behind a loader the dispatch gate rejects
|
||||
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
|
||||
// registered, not in DisabledTools, and advertised in the current permission
|
||||
// mode (e.g. not spec-draft, where tool_search is not advertised). The
|
||||
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
|
||||
// exempt from the allowlist at dispatch, so an allowlist that omits it must
|
||||
// not disable deferral.
|
||||
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
|
||||
loaderUsable := loaderFound &&
|
||||
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
|
||||
ToolAdvertised(loader, permissionMode)
|
||||
|
||||
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
||||
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold
|
||||
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
exposedNames := make(map[string]bool, len(visible))
|
||||
@@ -976,18 +927,20 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
exposedNames[name] = true
|
||||
}
|
||||
|
||||
// On the ACTIVE path tool_search is guaranteed runnable (active implies
|
||||
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
|
||||
// EnabledTools allowlist omits it (the operator allowlisted the deferred
|
||||
// tools, not the loader). Expose its full definition whenever it is not
|
||||
// already in the exposed set. This never runs on the inactive path, so the
|
||||
// On the ACTIVE path, tool_search is the gateway to the allowlisted deferred
|
||||
// tools, so it must ALWAYS be reachable — even when a non-empty EnabledTools
|
||||
// allowlist omits it (the operator allowlisted the deferred tools, not the
|
||||
// loader). Expose its full definition unless an explicit DisabledTools entry
|
||||
// turns the loader itself off. This never runs on the inactive path, so the
|
||||
// byte-identical below-threshold output is preserved.
|
||||
if active && !exposedNames[tools.ToolSearchToolName] {
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: loader.Description(),
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
if active && !exposedNames[tools.ToolSearchToolName] && !containsToolName(options.DisabledTools, tools.ToolSearchToolName) {
|
||||
if loader, ok := registry.Get(tools.ToolSearchToolName); ok {
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: loader.Description(),
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
|
||||
@@ -58,11 +58,6 @@ type ToolResult struct {
|
||||
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
|
||||
// ordinary tool result; only tool_search populates it.
|
||||
LoadedTools []string
|
||||
// RequestedModel is the model id a tool asked the loop to switch to for the
|
||||
// rest of the run (lifted from the tool's Meta["escalate_to_model"]). Empty
|
||||
// for every normal tool result; the Run loop performs the switch when it is
|
||||
// set and Options.ModelSwitcher is wired.
|
||||
RequestedModel string
|
||||
}
|
||||
|
||||
// DenialCategory classifies why a tool call was blocked before it executed.
|
||||
@@ -181,13 +176,6 @@ type Options struct {
|
||||
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
||||
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
||||
OnContext func(ContextBreakdown)
|
||||
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
|
||||
// mid-run: the loop calls it with the requested model id and, on success,
|
||||
// swaps the active provider and updates Options.Model for the rest of the
|
||||
// run. nil DISABLES escalation entirely (the loop ignores any switch
|
||||
// request), so every existing caller is unaffected. A returned error is
|
||||
// non-fatal: the run continues on the current model.
|
||||
ModelSwitcher func(ctx context.Context, modelID string) (Provider, error)
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
|
||||
@@ -181,8 +181,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runSearch(args[1:], stdout, stderr, deps)
|
||||
case "sessions", "session":
|
||||
return runSessions(args[1:], stdout, stderr, deps)
|
||||
case "spec":
|
||||
return runSpec(args[1:], stdout, stderr, deps)
|
||||
case "specialists", "specialist":
|
||||
return runSpecialists(args[1:], stdout, stderr, deps)
|
||||
case "plugins", "plugin":
|
||||
@@ -380,7 +378,6 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string, perm
|
||||
PermissionMode: permissionMode,
|
||||
Skin: skin,
|
||||
ThemeDark: true,
|
||||
Notify: resolved.Notify,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -408,9 +405,6 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*s
|
||||
}
|
||||
|
||||
func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
||||
if options.useSpec {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
|
||||
return false
|
||||
}
|
||||
@@ -462,7 +456,6 @@ Commands:
|
||||
search Search persisted local Zero session events
|
||||
find Alias for search
|
||||
sessions Inspect local Zero session lineage
|
||||
spec Review and approve saved spec-mode drafts
|
||||
specialist Manage local Zero specialist profiles
|
||||
plugins Inspect local Zero plugin manifests
|
||||
skills Inspect local Zero skills
|
||||
@@ -506,10 +499,6 @@ Flags:
|
||||
--image <path> Attach a local image (repeatable; vision models only)
|
||||
--mode <name> Apply a preset (smart, deep, fast, large, precise); explicit flags override it
|
||||
-m, --model <model> Select the model for provider setup
|
||||
--use-spec Draft a spec first and stop for review
|
||||
--spec-model <model> Override the draft model when --use-spec is set
|
||||
--spec-reasoning-effort <effort>
|
||||
Override draft reasoning effort when --use-spec is set
|
||||
--max-turns <number> Override the maximum agent loop turns
|
||||
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
|
||||
--enabled-tools <tools> Only expose these comma or space separated tools
|
||||
@@ -535,10 +524,6 @@ Flags:
|
||||
--session-title <text> Set the created session title
|
||||
--init-session-id <id> Create a new exec session with this id
|
||||
--skip-permissions-unsafe Allow prompt-gated tools without approval
|
||||
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
|
||||
--notify <off|bell|notify|both>
|
||||
Override notification mode for this run
|
||||
--no-notify Disable notifications for this run
|
||||
`)
|
||||
return err
|
||||
}
|
||||
+9
-132
@@ -14,12 +14,10 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/worktrees"
|
||||
@@ -60,9 +58,6 @@ type execOptions struct {
|
||||
// selection") and TestRunExecAcceptsLegacyModelProfileFlags.
|
||||
modelProfile string
|
||||
reasoningEffort string
|
||||
useSpec bool
|
||||
specModel string
|
||||
specReasoningEffort string
|
||||
maxTurns int
|
||||
cwd string
|
||||
inputFormat execInputFormat
|
||||
@@ -84,16 +79,6 @@ type execOptions struct {
|
||||
worktreeName string
|
||||
worktreeDir string
|
||||
skipPermissionsUnsafe bool
|
||||
// allowEscalation opts the run into mid-run model escalation: it registers
|
||||
// the escalate_model tool and wires agent.Options.ModelSwitcher. Off by
|
||||
// default — a run without the flag is byte-identical to before (no tool, nil
|
||||
// switcher).
|
||||
allowEscalation bool
|
||||
// notifyMode overrides config.Notify.Mode for this run. Mutually exclusive
|
||||
// with noNotify.
|
||||
notifyMode string
|
||||
// noNotify forces ModeOff for this run. Mutually exclusive with notifyMode.
|
||||
noNotify bool
|
||||
}
|
||||
|
||||
type execUsageError struct {
|
||||
@@ -144,14 +129,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
|
||||
registry := newCoreRegistry(workspaceRoot)
|
||||
// Register the escalate_model tool only when the run opted into mid-run
|
||||
// escalation. It is registered before --list-tools formatting and
|
||||
// validateExecToolFilters so the tool is both listable and filter-validatable.
|
||||
// The no-arg constructor builds its own default registry internally (and
|
||||
// degrades to an inert tool if the catalog fails), so no registry is threaded.
|
||||
if options.allowEscalation {
|
||||
registry.Register(tools.NewEscalateModelTool())
|
||||
}
|
||||
var specialistRuntime *specialist.Runtime
|
||||
if shouldRegisterExecSpecialistTools(options) {
|
||||
var err error
|
||||
@@ -165,17 +142,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
if options.useSpec {
|
||||
permissionMode = agent.PermissionModeSpecDraft
|
||||
}
|
||||
mcpRuntime, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options))
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
if options.useSpec {
|
||||
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
|
||||
}
|
||||
if err := validateExecToolFilters(options, registry); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
@@ -207,12 +178,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
modelRegistry, _ := modelregistry.DefaultRegistry()
|
||||
|
||||
overrides := config.Overrides{}
|
||||
modelOverride := options.model
|
||||
if options.useSpec && options.specModel != "" {
|
||||
modelOverride = options.specModel
|
||||
}
|
||||
if modelOverride != "" {
|
||||
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
|
||||
if options.model != "" {
|
||||
resolvedModel, notice := resolveSelectedModel(modelRegistry, options.model)
|
||||
overrides.Provider.Model = resolvedModel
|
||||
if notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, notice); err != nil {
|
||||
@@ -237,16 +204,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// permission mode and operator tool filters MUST match the values passed to
|
||||
// agent.Run below so this registration gate counts the same population the
|
||||
// loop's partition gate counts.
|
||||
// tool_search is the only way to reach a hidden deferred tool, so if the
|
||||
// operator explicitly disables it, deferral must not activate at all —
|
||||
// otherwise a positive threshold would hide tools behind a loader the run
|
||||
// rejects (a dead-end). Force the effective threshold to 0 so this
|
||||
// registration gate and agent.Run's partition gate agree the run is inactive.
|
||||
effectiveDeferThreshold := resolved.Tools.DeferThreshold
|
||||
if toolListContains(options.disabledTools, tools.ToolSearchToolName) {
|
||||
effectiveDeferThreshold = 0
|
||||
}
|
||||
registerToolSearchIfEligible(registry, effectiveDeferThreshold, permissionMode, options.enabledTools, options.disabledTools)
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, options.enabledTools, options.disabledTools)
|
||||
images, err := resolveExecImages(options.imagePaths, workspaceRoot)
|
||||
if err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
@@ -269,16 +227,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
runReasoningEffort := options.reasoningEffort
|
||||
if options.useSpec && options.specReasoningEffort != "" {
|
||||
runReasoningEffort = options.specReasoningEffort
|
||||
}
|
||||
// Evaluate the --reasoning-effort advisory against the EFFECTIVE resolved
|
||||
// model (resolved.Provider.Model), not the override. Without an explicit
|
||||
// --model the override model is empty, so checking it here would silently
|
||||
// skip the advisory even though the run uses a concrete effective model.
|
||||
if runReasoningEffort != "" {
|
||||
if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, runReasoningEffort); notice != "" {
|
||||
if options.reasoningEffort != "" {
|
||||
if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, options.reasoningEffort); notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
@@ -289,63 +243,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
|
||||
// currentModel tracks the model in force for usage attribution. It starts at
|
||||
// the resolved model and is reassigned by the model switcher on a mid-run
|
||||
// escalation so post-switch turns are attributed to the escalated model.
|
||||
currentModel := resolved.Provider.Model
|
||||
var modelSwitcher func(context.Context, string) (agent.Provider, error)
|
||||
if options.allowEscalation {
|
||||
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
|
||||
switchedProfile := resolved.Provider
|
||||
switchedProfile.Model = modelID
|
||||
switchedProvider, err := deps.newProvider(switchedProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Mirror the agent loop's switch guard (it only reassigns the provider
|
||||
// when newProvider != nil). Updating currentModel only on a non-nil
|
||||
// provider keeps usage attribution consistent with whether the loop
|
||||
// actually switched — a (nil, nil) return leaves both untouched.
|
||||
if switchedProvider != nil {
|
||||
currentModel = modelID
|
||||
}
|
||||
return switchedProvider, nil
|
||||
}
|
||||
}
|
||||
|
||||
runMetadata, err := resolveExecRunMetadata(resolved.Provider)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
// Notify on completion via stderr (never stdout, which may carry stream-json).
|
||||
// Headless has no terminal-focus signal, so focus gating does not apply here:
|
||||
// always emit when a mode is configured (focusMode is a TUI-only concept).
|
||||
notifier := notify.New(stderr, notify.Config{
|
||||
Mode: notify.Mode(strings.TrimSpace(execNotifyMode(options, resolved))),
|
||||
FocusMode: notify.FocusAlways,
|
||||
})
|
||||
if options.useSpec {
|
||||
return runExecSpecDraft(execSpecDraftRun{
|
||||
options: options,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
deps: deps,
|
||||
workspaceRoot: workspaceRoot,
|
||||
registry: registry,
|
||||
modelRegistry: modelRegistry,
|
||||
resolved: resolved,
|
||||
runMetadata: runMetadata,
|
||||
provider: provider,
|
||||
sandboxEngine: sandboxEngine,
|
||||
prompt: prompt,
|
||||
sessionTitle: sessionTitle,
|
||||
images: images,
|
||||
reasoningEffort: runReasoningEffort,
|
||||
specPermissionMode: permissionMode,
|
||||
notifier: notifier,
|
||||
})
|
||||
}
|
||||
|
||||
preparedSession := sessions.PreparedExec{}
|
||||
agentPrompt := prompt
|
||||
@@ -419,7 +320,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
CallingSessionID: options.callingSessionID,
|
||||
CallingToolUseID: options.callingToolUseID,
|
||||
@@ -427,8 +328,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
Depth: options.depth,
|
||||
SessionTitle: sessionTitle,
|
||||
Model: resolved.Provider.Model,
|
||||
ModelSwitcher: modelSwitcher,
|
||||
ReasoningEffort: runReasoningEffort,
|
||||
ReasoningEffort: options.reasoningEffort,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
@@ -475,23 +375,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
},
|
||||
OnUsage: func(usage agent.Usage) {
|
||||
writer.usage(usage)
|
||||
payload := map[string]any{
|
||||
sessionRecorder.append(sessions.EventUsage, map[string]any{
|
||||
"promptTokens": usage.EffectiveInputTokens(),
|
||||
"completionTokens": usage.EffectiveOutputTokens(),
|
||||
"totalTokens": usage.TotalTokens(),
|
||||
}
|
||||
// Attribute usage to a specific model ONLY when escalation is enabled:
|
||||
// the model in force can change mid-run only under --allow-escalation, so
|
||||
// the "model" key is meaningful exclusively then. Omitting it otherwise
|
||||
// keeps a non-escalation run's persisted usage payload byte-identical to
|
||||
// before this feature (the origin/main guarantee).
|
||||
if options.allowEscalation {
|
||||
payload["model"] = currentModel
|
||||
}
|
||||
sessionRecorder.append(sessions.EventUsage, payload)
|
||||
})
|
||||
},
|
||||
})
|
||||
notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
@@ -917,16 +807,3 @@ func sessionPermissionEventType(event agent.PermissionEvent) sessions.EventType
|
||||
}
|
||||
return sessions.EventPermission
|
||||
}
|
||||
|
||||
// execNotifyMode resolves the effective notification mode for a run:
|
||||
// --no-notify forces "off", --notify <mode> overrides config, otherwise the
|
||||
// config value is used.
|
||||
func execNotifyMode(options execOptions, resolved config.ResolvedConfig) string {
|
||||
if options.noNotify {
|
||||
return string(notify.ModeOff)
|
||||
}
|
||||
if options.notifyMode != "" {
|
||||
return options.notifyMode
|
||||
}
|
||||
return resolved.Notify.Mode
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
)
|
||||
|
||||
func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
@@ -24,23 +23,6 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
options.skipPermissionsUnsafe = true
|
||||
case arg == "--list-tools":
|
||||
options.listTools = true
|
||||
case arg == "--allow-escalation":
|
||||
options.allowEscalation = true
|
||||
case arg == "--no-notify":
|
||||
options.noNotify = true
|
||||
case arg == "--notify":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.notifyMode = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--notify="):
|
||||
value, err := requiredInlineFlagValue(arg, "--notify")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.notifyMode = value
|
||||
case arg == "--auto":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -130,34 +112,6 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--reasoning-effort="):
|
||||
options.reasoningEffort = strings.TrimSpace(strings.TrimPrefix(arg, "--reasoning-effort="))
|
||||
case arg == "--use-spec":
|
||||
options.useSpec = true
|
||||
case arg == "--spec-model":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.specModel = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--spec-model="):
|
||||
value, err := requiredInlineFlagValue(arg, "--spec-model")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.specModel = value
|
||||
case arg == "--spec-reasoning-effort":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.specReasoningEffort = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--spec-reasoning-effort="):
|
||||
value, err := requiredInlineFlagValue(arg, "--spec-reasoning-effort")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.specReasoningEffort = value
|
||||
case arg == "--max-turns":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -360,31 +314,9 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if options.noNotify && options.notifyMode != "" {
|
||||
return options, false, execUsageError{"Use either --notify or --no-notify, not both."}
|
||||
}
|
||||
if options.notifyMode != "" {
|
||||
switch options.notifyMode {
|
||||
case "off", "bell", "notify", "both":
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("invalid --notify %q. Expected off, bell, notify, or both.", options.notifyMode)}
|
||||
}
|
||||
}
|
||||
if (options.resume != "" || options.resumeLatest) && options.fork != "" {
|
||||
return options, false, execUsageError{"Use either --resume or --fork, not both."}
|
||||
}
|
||||
if options.useSpec && (options.resume != "" || options.resumeLatest || options.fork != "") {
|
||||
return options, false, execUsageError{"--use-spec cannot be combined with --resume or --fork."}
|
||||
}
|
||||
if options.useSpec && strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
|
||||
return options, false, execUsageError{"--use-spec cannot be used inside a specialist child session."}
|
||||
}
|
||||
if !options.useSpec && options.specModel != "" {
|
||||
return options, false, execUsageError{"--spec-model requires --use-spec."}
|
||||
}
|
||||
if !options.useSpec && options.specReasoningEffort != "" {
|
||||
return options, false, execUsageError{"--spec-reasoning-effort requires --use-spec."}
|
||||
}
|
||||
if options.initSessionID != "" && (options.resume != "" || options.resumeLatest) {
|
||||
return options, false, execUsageError{"Use --init-session-id only when creating or forking a session."}
|
||||
}
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type execSpecDraftRun struct {
|
||||
options execOptions
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
deps appDeps
|
||||
workspaceRoot string
|
||||
registry *tools.Registry
|
||||
modelRegistry modelregistry.Registry
|
||||
resolved config.ResolvedConfig
|
||||
runMetadata execRunMetadata
|
||||
provider zeroruntime.Provider
|
||||
sandboxEngine *sandbox.Engine
|
||||
prompt string
|
||||
sessionTitle string
|
||||
images []zeroruntime.ImageBlock
|
||||
reasoningEffort string
|
||||
specPermissionMode agent.PermissionMode
|
||||
notifier *notify.Notifier
|
||||
}
|
||||
|
||||
type execSpecDraftInfo struct {
|
||||
SpecID string
|
||||
SpecTitle string
|
||||
SpecFilePath string
|
||||
RelativePath string
|
||||
DraftSessionID string
|
||||
}
|
||||
|
||||
func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
store := run.deps.newSessionStore()
|
||||
draftSession, err := store.Create(sessions.CreateInput{
|
||||
SessionID: run.options.initSessionID,
|
||||
SessionKind: sessions.SessionKindSpecDraft,
|
||||
Title: run.sessionTitle,
|
||||
Cwd: run.workspaceRoot,
|
||||
ModelID: run.resolved.Provider.Model,
|
||||
Provider: run.runMetadata.Provider,
|
||||
SpecDraftModelID: run.resolved.Provider.Model,
|
||||
SpecDraftReasoning: run.reasoningEffort,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecFormatUsageError(run.stdout, run.stderr, run.options.outputFormat, err.Error())
|
||||
}
|
||||
runID, err := streamjson.CreateRunID(run.deps.now())
|
||||
if err != nil {
|
||||
return writeAppError(run.stderr, "failed to create run id: "+err.Error(), exitCrash)
|
||||
}
|
||||
writer := execEventWriter{
|
||||
stdout: run.stdout,
|
||||
stderr: run.stderr,
|
||||
format: run.options.outputFormat,
|
||||
runID: runID,
|
||||
sessionID: draftSession.SessionID,
|
||||
streamedText: &strings.Builder{},
|
||||
}
|
||||
writer.runStart(run.workspaceRoot, run.runMetadata, run.specPermissionMode)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
|
||||
preparedSession := sessions.PreparedExec{
|
||||
Mode: sessions.ModeNew,
|
||||
Session: draftSession,
|
||||
Store: store,
|
||||
}
|
||||
sessionRecorder := execSessionRecorder{prepared: preparedSession}
|
||||
sessionRecorder.append(sessions.EventMessage, map[string]any{
|
||||
"role": "user",
|
||||
"content": run.prompt,
|
||||
})
|
||||
|
||||
var draftInfo execSpecDraftInfo
|
||||
runCtx, stopSignals := signalContext()
|
||||
defer stopSignals()
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: modelContextWindow(run.modelRegistry, run.resolved.Provider.Model),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
Model: run.resolved.Provider.Model,
|
||||
ReasoningEffort: run.reasoningEffort,
|
||||
Cwd: run.workspaceRoot,
|
||||
SystemPrompt: specmode.DraftSystemPrompt,
|
||||
Images: run.images,
|
||||
Registry: run.registry,
|
||||
PermissionMode: agent.PermissionModeSpecDraft,
|
||||
Autonomy: "low",
|
||||
Sandbox: run.sandboxEngine,
|
||||
EnabledTools: run.options.enabledTools,
|
||||
DisabledTools: run.options.disabledTools,
|
||||
OnText: writer.text,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
writer.toolCall(call, run.registry)
|
||||
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
||||
"id": call.ID,
|
||||
"name": call.Name,
|
||||
"arguments": call.Arguments,
|
||||
})
|
||||
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
|
||||
writer.checkpoint(checkpoint)
|
||||
}
|
||||
},
|
||||
OnPermission: func(event agent.PermissionEvent) {
|
||||
writer.permission(event)
|
||||
sessionRecorder.append(sessionPermissionEventType(event), event)
|
||||
},
|
||||
OnToolResult: func(result agent.ToolResult) {
|
||||
writer.toolResult(result)
|
||||
if info, ok := execSpecDraftInfoFromToolResult(result); ok {
|
||||
draftInfo = info
|
||||
}
|
||||
payload := map[string]any{
|
||||
"toolCallId": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": string(result.Status),
|
||||
"output": result.Output,
|
||||
}
|
||||
if len(result.Meta) > 0 {
|
||||
payload["meta"] = result.Meta
|
||||
}
|
||||
if result.Redacted {
|
||||
payload["redacted"] = true
|
||||
}
|
||||
if len(result.ChangedFiles) > 0 {
|
||||
payload["changedFiles"] = result.ChangedFiles
|
||||
}
|
||||
sessionRecorder.append(sessions.EventToolResult, payload)
|
||||
},
|
||||
OnUsage: func(usage agent.Usage) {
|
||||
writer.usage(usage)
|
||||
sessionRecorder.append(sessions.EventUsage, map[string]any{
|
||||
"promptTokens": usage.EffectiveInputTokens(),
|
||||
"completionTokens": usage.EffectiveOutputTokens(),
|
||||
"totalTokens": usage.TotalTokens(),
|
||||
})
|
||||
},
|
||||
})
|
||||
run.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
|
||||
if run.options.outputFormat == execOutputStreamJSON {
|
||||
writer.errorEvent("interrupted", "run cancelled by signal", false)
|
||||
writer.runEnd("interrupted", exitInterrupted)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(run.stderr, "Interrupted.")
|
||||
}
|
||||
return exitInterrupted
|
||||
}
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
|
||||
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, err.Error())
|
||||
}
|
||||
if result.StopReason != agent.StopReasonSpecReviewRequired || draftInfo.SpecID == "" || draftInfo.SpecFilePath == "" {
|
||||
message := "spec draft ended without submit_spec"
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": message})
|
||||
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, message)
|
||||
}
|
||||
draftInfo.DraftSessionID = draftSession.SessionID
|
||||
if _, _, err := store.RecordSpec(draftSession.SessionID, sessions.RecordSpecInput{
|
||||
SpecID: draftInfo.SpecID,
|
||||
SpecFilePath: draftInfo.SpecFilePath,
|
||||
SpecStatus: sessions.SpecStatusDraft,
|
||||
SpecDraftModelID: run.resolved.Provider.Model,
|
||||
SpecDraftReasoning: run.reasoningEffort,
|
||||
}); err != nil {
|
||||
return writeAppError(run.stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
if emittedTerminal := writer.specReviewRequired(draftInfo); !emittedTerminal {
|
||||
writer.runEnd(string(agent.StopReasonSpecReviewRequired), exitSuccess)
|
||||
}
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func execSpecDraftInfoFromToolResult(result agent.ToolResult) (execSpecDraftInfo, bool) {
|
||||
if result.Name != specmode.SubmitToolName || result.Meta["control"] != specmode.ControlSpecReviewRequired {
|
||||
return execSpecDraftInfo{}, false
|
||||
}
|
||||
return execSpecDraftInfo{
|
||||
SpecID: strings.TrimSpace(result.Meta["specId"]),
|
||||
SpecTitle: strings.TrimSpace(result.Meta["specTitle"]),
|
||||
SpecFilePath: strings.TrimSpace(result.Meta["specFilePath"]),
|
||||
RelativePath: strings.TrimSpace(result.Meta["relativePath"]),
|
||||
}, true
|
||||
}
|
||||
|
||||
func writeExecSpecDraftProviderError(writer *execEventWriter, format execOutputFormat, message string) int {
|
||||
if format == execOutputStreamJSON {
|
||||
writer.errorEvent("provider_error", message, false)
|
||||
writer.runEnd("error", exitProvider)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitProvider
|
||||
}
|
||||
if format == execOutputJSON {
|
||||
writer.errorEvent("provider_error", message, false)
|
||||
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitProvider})
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitProvider
|
||||
}
|
||||
writer.errorEvent("provider_error", message, false)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitProvider
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) specReviewRequired(info execSpecDraftInfo) bool {
|
||||
summary := formatExecSpecDraftSummary(info)
|
||||
switch writer.format {
|
||||
case execOutputJSON:
|
||||
writer.writeJSON(map[string]any{
|
||||
"type": string(agent.StopReasonSpecReviewRequired),
|
||||
"status": string(sessions.SpecStatusDraft),
|
||||
"stop_reason": string(agent.StopReasonSpecReviewRequired),
|
||||
"spec_id": info.SpecID,
|
||||
"spec_title": info.SpecTitle,
|
||||
"spec_file_path": info.SpecFilePath,
|
||||
"relative_path": info.RelativePath,
|
||||
"draft_session_id": info.DraftSessionID,
|
||||
})
|
||||
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitSuccess})
|
||||
return true
|
||||
case execOutputStreamJSON:
|
||||
writer.writeStreamJSON(streamjson.Event{
|
||||
Type: streamjson.EventFinal,
|
||||
RunID: writer.runID,
|
||||
Text: summary,
|
||||
Meta: map[string]string{
|
||||
"status": string(sessions.SpecStatusDraft),
|
||||
"stopReason": string(agent.StopReasonSpecReviewRequired),
|
||||
"specId": info.SpecID,
|
||||
"specTitle": info.SpecTitle,
|
||||
"specFilePath": info.SpecFilePath,
|
||||
"relativePath": info.RelativePath,
|
||||
"draftSessionId": info.DraftSessionID,
|
||||
},
|
||||
})
|
||||
return false
|
||||
default:
|
||||
if writer.streamedText.Len() > 0 && !strings.HasSuffix(writer.streamedText.String(), "\n") {
|
||||
writer.writeStdout("\n")
|
||||
}
|
||||
writer.writeStdout(summary + "\n")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func formatExecSpecDraftSummary(info execSpecDraftInfo) string {
|
||||
path := strings.TrimSpace(info.RelativePath)
|
||||
if path == "" {
|
||||
path = info.SpecFilePath
|
||||
}
|
||||
lines := []string{
|
||||
"Spec draft saved for review.",
|
||||
" spec: " + redact(info.SpecID),
|
||||
" path: " + redact(path),
|
||||
" draft session: " + redact(info.DraftSessionID),
|
||||
"Next: zero spec show " + redact(info.SpecID) + "; zero spec approve " + redact(info.SpecID),
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestRunExecUseSpecCreatesDraftSession(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLISpecTime})
|
||||
provider := &submitSpecExecProvider{}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec",
|
||||
"--cwd", workspaceRoot,
|
||||
"--use-spec",
|
||||
"--spec-model", "draft-model",
|
||||
"--spec-reasoning-effort", "high",
|
||||
"-o", "json",
|
||||
"plan the review flow",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return workspaceRoot, nil
|
||||
},
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "default-model"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "test",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "test",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: model,
|
||||
},
|
||||
MaxTurns: 3,
|
||||
}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return provider, nil
|
||||
},
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
||||
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
||||
},
|
||||
now: fixedCLISpecTime,
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
if !slicesContains(jsonEventTypes(events), string(agent.StopReasonSpecReviewRequired)) {
|
||||
t.Fatalf("missing spec_review_required event in %v", jsonEventTypes(events))
|
||||
}
|
||||
items, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("session count = %d, want 1: %#v", len(items), items)
|
||||
}
|
||||
draft := items[0]
|
||||
if draft.SessionKind != sessions.SessionKindSpecDraft || draft.SpecStatus != sessions.SpecStatusDraft {
|
||||
t.Fatalf("unexpected draft session: %#v", draft)
|
||||
}
|
||||
if draft.SpecID == "" || !strings.Contains(filepath.ToSlash(draft.SpecFilePath), ".zero/specs/") {
|
||||
t.Fatalf("draft spec metadata missing: %#v", draft)
|
||||
}
|
||||
if draft.SpecDraftModelID != "draft-model" || draft.ModelID != "draft-model" || draft.SpecDraftReasoning != "high" {
|
||||
t.Fatalf("draft model metadata = %#v", draft)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("provider request count = %d, want 1", len(provider.requests))
|
||||
}
|
||||
if !provider.requestIncludesTool(specmode.SubmitToolName) {
|
||||
t.Fatalf("provider tools missing submit_spec: %#v", provider.requests[0].Tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecUseSpecRejectsResume(t *testing.T) {
|
||||
_, _, err := parseExecArgs([]string{"--use-spec", "--resume", "zero_1", "plan"})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot be combined") {
|
||||
t.Fatalf("expected --use-spec/--resume validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecSpecOverridesRequireUseSpec(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "spec model",
|
||||
args: []string{"--spec-model", "gpt-5", "plan"},
|
||||
want: "--spec-model requires --use-spec",
|
||||
},
|
||||
{
|
||||
name: "spec reasoning effort",
|
||||
args: []string{"--spec-reasoning-effort", "high", "plan"},
|
||||
want: "--spec-reasoning-effort requires --use-spec",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := parseExecArgs(tc.args)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected %q validation, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecUseSpecRejectsSpecialistTag(t *testing.T) {
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{"exec", "--use-spec", "--tag", "specialist", "plan"})
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("exit = %d, want %d stderr=%s", exitCode, exitUsage, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "specialist child session") {
|
||||
t.Fatalf("expected --use-spec specialist tag validation, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecUseSpecRejectsFiltersThatHideSubmitSpec(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"exec", "--use-spec", "--disabled-tools", specmode.SubmitToolName, "plan"},
|
||||
{"exec", "--use-spec", "--enabled-tools", "read_file", "plan"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
exitCode, _, stderr := runExecWithEcho(t, args)
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("exit = %d, want %d stderr=%s", exitCode, exitUsage, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "submit_spec") {
|
||||
t.Fatalf("expected submit_spec validation, got %q", stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type submitSpecExecProvider struct {
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func (provider *submitSpecExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.requests = append(provider.requests, request)
|
||||
arguments, _ := json.Marshal(map[string]string{
|
||||
"title": "Review Flow",
|
||||
"plan": "# Goal\n\nAdd the review flow.",
|
||||
})
|
||||
ch := make(chan zeroruntime.StreamEvent, 4)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(ch)
|
||||
return ch, ctx.Err()
|
||||
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: specmode.SubmitToolName}:
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: string(arguments)}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (provider *submitSpecExecProvider) requestIncludesTool(name string) bool {
|
||||
if len(provider.requests) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, tool := range provider.requests[0].Tools {
|
||||
if tool.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func slicesContains(values []string, value string) bool {
|
||||
for _, candidate := range values {
|
||||
if candidate == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1135,745 +1135,3 @@ func TestRunExecInvalidAutoValidatedWithSkipPermissions(t *testing.T) {
|
||||
t.Fatalf("expected autonomy validation error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecAllowEscalationFlag(t *testing.T) {
|
||||
t.Run("absent defaults to false", func(t *testing.T) {
|
||||
options, help, err := parseExecArgs([]string{"hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs returned error: %v", err)
|
||||
}
|
||||
if help {
|
||||
t.Fatal("help = true, want false")
|
||||
}
|
||||
if options.allowEscalation {
|
||||
t.Fatal("allowEscalation = true, want false by default")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flag sets true", func(t *testing.T) {
|
||||
options, _, err := parseExecArgs([]string{"--allow-escalation", "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs returned error: %v", err)
|
||||
}
|
||||
if !options.allowEscalation {
|
||||
t.Fatal("allowEscalation = false, want true")
|
||||
}
|
||||
if strings.Join(options.promptParts, " ") != "hello" {
|
||||
t.Fatalf("promptParts = %#v, want [hello]", options.promptParts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunExecHelpDocumentsAllowEscalation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"exec", "--help"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "--allow-escalation") {
|
||||
t.Fatalf("expected exec help to document --allow-escalation, got %q", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecRegistersEscalateModelOnlyWithFlag(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool bool
|
||||
}{
|
||||
{name: "absent", args: []string{"exec", "--list-tools"}, wantTool: false},
|
||||
{name: "present", args: []string{"exec", "--allow-escalation", "--list-tools"}, wantTool: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
hasTool := strings.Contains(stdout.String(), " escalate_model ")
|
||||
if hasTool != tc.wantTool {
|
||||
t.Fatalf("escalate_model visibility = %v, want %v; output:\n%s", hasTool, tc.wantTool, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// escalatingExecProvider drives an exec run through a mid-run switch without a
|
||||
// live model. Each instance tracks how many turns IT served (turns) so a test
|
||||
// can assert exactly which provider handled which turn across a switch.
|
||||
//
|
||||
// escalateOnce controls behavior: when true, the FIRST turn this instance serves
|
||||
// emits an escalate_model tool call (later turns answer); when false, every turn
|
||||
// emits a final text answer. Pointing escalateOnce at distinct instances lets a
|
||||
// test prove the post-switch turn lands on the SECOND (escalated) provider.
|
||||
type escalatingExecProvider struct {
|
||||
// turns counts how many StreamCompletion calls THIS instance handled.
|
||||
turns int
|
||||
// escalateOnce makes this instance's first served turn request an escalation.
|
||||
escalateOnce bool
|
||||
}
|
||||
|
||||
func (provider *escalatingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
turn := provider.turns
|
||||
provider.turns++
|
||||
ch := make(chan zeroruntime.StreamEvent, 4)
|
||||
if provider.escalateOnce && turn == 0 {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunExecWiresModelSwitcherUnderFlag(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
|
||||
var providerModels []string
|
||||
// Each newProvider build returns a DISTINCT provider instance with its OWN
|
||||
// turn counter, mirroring the agent-layer firstProvider/secondProvider split.
|
||||
// The first build (escalation source) must handle exactly the escalation turn;
|
||||
// the second build (escalation target) must handle exactly the post-switch
|
||||
// answer turn. Sharing a single counter would hide a dropped `provider =
|
||||
// newProvider` in loop.go — here it is mutation-proven by the per-instance
|
||||
// turn assertions below.
|
||||
var builtProviders []*escalatingExecProvider
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"exec", "--allow-escalation", "--model", "claude-haiku-4.5", "escalate please"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "claude-haiku-4.5"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
cfg := execResolvedConfig()
|
||||
// The escalation chain (haiku -> sonnet) is anthropic; declare the
|
||||
// provider kind to match so provider-metadata resolution accepts it.
|
||||
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
||||
cfg.Provider.Model = model
|
||||
cfg.MaxTurns = 3
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerModels = append(providerModels, profile.Model)
|
||||
// First build escalates; every later build answers. Each instance owns
|
||||
// its turn counter so we can assert exactly-one-turn per provider.
|
||||
provider := &escalatingExecProvider{escalateOnce: len(builtProviders) == 0}
|
||||
builtProviders = append(builtProviders, provider)
|
||||
return provider, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
// Initial provider build + one rebuild for the escalation target.
|
||||
if len(providerModels) != 2 {
|
||||
t.Fatalf("newProvider called for models %#v, want exactly 2 builds (initial + escalated)", providerModels)
|
||||
}
|
||||
if providerModels[0] != "claude-haiku-4.5" {
|
||||
t.Fatalf("initial provider model = %q, want claude-haiku-4.5", providerModels[0])
|
||||
}
|
||||
target, ok := mustUpgradeTarget(t, "claude-haiku-4.5")
|
||||
if !ok {
|
||||
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
||||
}
|
||||
if providerModels[1] != target {
|
||||
t.Fatalf("escalated provider model = %q, want %q", providerModels[1], target)
|
||||
}
|
||||
if len(builtProviders) != 2 {
|
||||
t.Fatalf("built %d providers, want 2", len(builtProviders))
|
||||
}
|
||||
// The original (escalation-source) provider handled ONLY the escalation turn;
|
||||
// the escalated (target) provider handled ONLY the post-switch answer turn.
|
||||
// This FAILS if loop.go drops `provider = newProvider` (the second provider
|
||||
// would then handle zero turns and the first would handle both).
|
||||
if builtProviders[0].turns != 1 {
|
||||
t.Fatalf("first (source) provider handled %d turns, want exactly 1 (the escalation turn)", builtProviders[0].turns)
|
||||
}
|
||||
if builtProviders[1].turns != 1 {
|
||||
t.Fatalf("second (escalated) provider handled %d turns, want exactly 1 (the post-switch answer turn)", builtProviders[1].turns)
|
||||
}
|
||||
}
|
||||
|
||||
func mustUpgradeTarget(t *testing.T, id string) (string, bool) {
|
||||
t.Helper()
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry: %v", err)
|
||||
}
|
||||
entry, ok := registry.UpgradeTarget(id)
|
||||
return entry.ID, ok
|
||||
}
|
||||
|
||||
func TestRunExecNoSwitcherWithoutFlag(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
|
||||
var providerModels []string
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"exec", "--model", "claude-haiku-4.5", "escalate please"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "claude-haiku-4.5"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
cfg := execResolvedConfig()
|
||||
// The escalation chain (haiku -> sonnet) is anthropic; declare the
|
||||
// provider kind to match so provider-metadata resolution accepts it.
|
||||
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
||||
cfg.Provider.Model = model
|
||||
cfg.MaxTurns = 3
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerModels = append(providerModels, profile.Model)
|
||||
return &escalatingExecProvider{escalateOnce: true}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if len(providerModels) != 1 {
|
||||
t.Fatalf("newProvider called for %#v, want exactly 1 build (no switcher wired)", providerModels)
|
||||
}
|
||||
if providerModels[0] != "claude-haiku-4.5" {
|
||||
t.Fatalf("provider model = %q, want claude-haiku-4.5", providerModels[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecAttributesUsageToEscalatedModel(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
|
||||
target, ok := mustUpgradeTarget(t, "claude-haiku-4.5")
|
||||
if !ok {
|
||||
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
||||
}
|
||||
|
||||
// The escalation turn (turn 0, pre-switch) emits usage with distinct tokens so
|
||||
// it can be told apart from the post-switch usage; the first build escalates,
|
||||
// the second answers.
|
||||
builds := 0
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec",
|
||||
"--allow-escalation",
|
||||
"--model", "claude-haiku-4.5",
|
||||
"--init-session-id", "escalation_run",
|
||||
"escalate please",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "claude-haiku-4.5"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
cfg := execResolvedConfig()
|
||||
// The escalation chain (haiku -> sonnet) is anthropic; declare the
|
||||
// provider kind to match so provider-metadata resolution accepts it.
|
||||
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
||||
cfg.Provider.Model = model
|
||||
cfg.MaxTurns = 3
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
// First build = escalation source (escalate + usage 3/4 pre-switch);
|
||||
// second build = escalation target (answer + usage 5/7 post-switch).
|
||||
escalate := builds == 0
|
||||
builds++
|
||||
return &usageEmittingEscalatingProvider{escalate: escalate}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
||||
events, err := store.ReadEvents("escalation_run")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
// Collect every usage model attribution IN ORDER. The pre-switch usage must
|
||||
// attribute to the ORIGINAL model and the post-switch usage to the escalated
|
||||
// target — proving the loop's ordering guarantee (usage is attributed to the
|
||||
// model that produced it, not retroactively reassigned after the switch).
|
||||
var usageModels []string
|
||||
for _, event := range events {
|
||||
if event.Type != sessions.EventUsage {
|
||||
continue
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatalf("unmarshal usage payload: %v", err)
|
||||
}
|
||||
model, _ := payload["model"].(string)
|
||||
usageModels = append(usageModels, model)
|
||||
}
|
||||
if len(usageModels) != 2 {
|
||||
t.Fatalf("recorded usage model attributions = %#v, want exactly 2 (pre- and post-switch)", usageModels)
|
||||
}
|
||||
if usageModels[0] != "claude-haiku-4.5" {
|
||||
t.Fatalf("first (pre-switch) usage model = %q, want claude-haiku-4.5", usageModels[0])
|
||||
}
|
||||
if usageModels[1] != target {
|
||||
t.Fatalf("second (post-switch) usage model = %q, want escalated target %q", usageModels[1], target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecNilSwitchProviderKeepsOriginalAttribution(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
|
||||
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
|
||||
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
||||
}
|
||||
|
||||
// The escalation rebuild returns (nil, nil): the loop must NOT swap the
|
||||
// provider, and the CLI switcher must leave currentModel unchanged so usage
|
||||
// after the declined switch stays attributed to the ORIGINAL model. Without
|
||||
// the switchedProvider != nil guard, currentModel would advance to the target
|
||||
// even though no switch happened, misattributing the post-turn usage.
|
||||
builds := 0
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec",
|
||||
"--allow-escalation",
|
||||
"--model", "claude-haiku-4.5",
|
||||
"--init-session-id", "nil_switch_run",
|
||||
"escalate please",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "claude-haiku-4.5"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
cfg := execResolvedConfig()
|
||||
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
||||
cfg.Provider.Model = model
|
||||
cfg.MaxTurns = 3
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
builds++
|
||||
// First build = the original (haiku). The escalation rebuild returns
|
||||
// (nil, nil), so the loop keeps the original provider for every turn.
|
||||
if builds == 1 {
|
||||
return &escalateThenAnswerProvider{}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
||||
events, err := store.ReadEvents("nil_switch_run")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
sawUsage := false
|
||||
for _, event := range events {
|
||||
if event.Type != sessions.EventUsage {
|
||||
continue
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatalf("unmarshal usage payload: %v", err)
|
||||
}
|
||||
sawUsage = true
|
||||
if model, _ := payload["model"].(string); model != "claude-haiku-4.5" {
|
||||
t.Fatalf("usage attributed to %q after a (nil,nil) switch, want original claude-haiku-4.5", model)
|
||||
}
|
||||
}
|
||||
if !sawUsage {
|
||||
t.Fatal("expected at least one usage event")
|
||||
}
|
||||
}
|
||||
|
||||
// escalateThenAnswerProvider escalates on its first turn and answers afterward,
|
||||
// so a single instance can serve an entire run when no provider swap occurs
|
||||
// (e.g. a (nil,nil) switcher). It emits usage on every turn for attribution.
|
||||
type escalateThenAnswerProvider struct {
|
||||
turns int
|
||||
}
|
||||
|
||||
func (provider *escalateThenAnswerProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
turn := provider.turns
|
||||
provider.turns++
|
||||
ch := make(chan zeroruntime.StreamEvent, 6)
|
||||
if turn == 0 {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// usageEmittingEscalatingProvider emits a usage event on every turn it serves so
|
||||
// usage attribution can be traced across a mid-run switch. When escalate is set,
|
||||
// its turn requests an escalation (and emits pre-switch usage tokens 3/4);
|
||||
// otherwise it answers (post-switch usage tokens 5/7).
|
||||
type usageEmittingEscalatingProvider struct {
|
||||
escalate bool
|
||||
}
|
||||
|
||||
func (provider *usageEmittingEscalatingProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 6)
|
||||
if provider.escalate {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
|
||||
// Pre-switch usage: still attributed to the ORIGINAL model.
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
// Post-switch usage: attributed to the escalated model.
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// usageEmittingEchoProvider answers in a single turn and emits a usage event. It
|
||||
// has no escalation behavior, so it exercises a plain (flag-OFF) run that still
|
||||
// records usage.
|
||||
type usageEmittingEchoProvider struct{}
|
||||
|
||||
func (usageEmittingEchoProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 3)
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunExecUsageOmitsModelKeyWithoutEscalationFlag verifies the back-compat
|
||||
// guarantee: a run WITHOUT --allow-escalation persists EventUsage payloads that
|
||||
// carry NO "model" key (byte-identical to before the escalation feature), since
|
||||
// the model can never change mid-run when escalation is off. The flag-ON,
|
||||
// post-escalation case is covered by TestRunExecAttributesUsageToEscalatedModel.
|
||||
func TestRunExecUsageOmitsModelKeyWithoutEscalationFlag(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec",
|
||||
"--model", "claude-haiku-4.5",
|
||||
"--init-session-id", "no_escalation_run",
|
||||
"hello",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "claude-haiku-4.5"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
cfg := execResolvedConfig()
|
||||
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
||||
cfg.Provider.Model = model
|
||||
cfg.MaxTurns = 3
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return usageEmittingEchoProvider{}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
||||
events, err := store.ReadEvents("no_escalation_run")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
var sawUsage bool
|
||||
for _, event := range events {
|
||||
if event.Type != sessions.EventUsage {
|
||||
continue
|
||||
}
|
||||
sawUsage = true
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatalf("unmarshal usage payload: %v", err)
|
||||
}
|
||||
if _, ok := payload["model"]; ok {
|
||||
t.Fatalf("flag-OFF usage payload must NOT carry a model key, got %#v", payload)
|
||||
}
|
||||
}
|
||||
if !sawUsage {
|
||||
t.Fatal("expected at least one usage event to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// usageThenAnswerProvider serves a two-turn run on ONE instance: turn 0 requests
|
||||
// an escalation and emits usage, turn 1 answers and emits usage. It is used by
|
||||
// the switch-error test where no second provider is ever built, so a single
|
||||
// instance must serve both turns on the original model.
|
||||
type usageThenAnswerProvider struct {
|
||||
turns int
|
||||
}
|
||||
|
||||
func (provider *usageThenAnswerProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
turn := provider.turns
|
||||
provider.turns++
|
||||
ch := make(chan zeroruntime.StreamEvent, 6)
|
||||
if turn == 0 {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunExecSwitcherErrorKeepsOriginalModelAttribution verifies that when the
|
||||
// ModelSwitcher fails on the rebuild (deps.newProvider errors for the escalation
|
||||
// target), the run is NON-FATAL and stays on the original provider — and every
|
||||
// usage event, including the one AFTER the failed switch, stays attributed to the
|
||||
// ORIGINAL model (currentModel is never reassigned on a failed switch).
|
||||
func TestRunExecSwitcherErrorKeepsOriginalModelAttribution(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
|
||||
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
|
||||
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
||||
}
|
||||
|
||||
builds := 0
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec",
|
||||
"--allow-escalation",
|
||||
"--model", "claude-haiku-4.5",
|
||||
"--init-session-id", "switch_error_run",
|
||||
"escalate please",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "claude-haiku-4.5"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
cfg := execResolvedConfig()
|
||||
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
||||
cfg.Provider.Model = model
|
||||
cfg.MaxTurns = 3
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
builds++
|
||||
// The first build (original model) succeeds; the rebuild on escalation
|
||||
// FAILS, so the switcher returns an error and the run continues on the
|
||||
// original provider.
|
||||
if builds >= 2 {
|
||||
return nil, errors.New("provider rebuild failed")
|
||||
}
|
||||
return &usageThenAnswerProvider{}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected non-fatal switch error, exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if builds != 2 {
|
||||
t.Fatalf("newProvider builds = %d, want 2 (initial + one failed rebuild attempt)", builds)
|
||||
}
|
||||
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
||||
events, err := store.ReadEvents("switch_error_run")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
var usageModels []string
|
||||
for _, event := range events {
|
||||
if event.Type != sessions.EventUsage {
|
||||
continue
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatalf("unmarshal usage payload: %v", err)
|
||||
}
|
||||
model, _ := payload["model"].(string)
|
||||
usageModels = append(usageModels, model)
|
||||
}
|
||||
if len(usageModels) != 2 {
|
||||
t.Fatalf("recorded usage attributions = %#v, want 2", usageModels)
|
||||
}
|
||||
// Both usages (pre- AND post-failed-switch) stay on the ORIGINAL model.
|
||||
for i, model := range usageModels {
|
||||
if model != "claude-haiku-4.5" {
|
||||
t.Fatalf("usage[%d] model = %q, want claude-haiku-4.5 (currentModel must not change on a failed switch)", i, model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunExecTopTierDeclineNoSwitch verifies an end-to-end flag-ON run where the
|
||||
// agent calls escalate_model while ALREADY on a top-tier model (claude-opus-4.1):
|
||||
// the tool returns the informational no-meta result, NO switch happens
|
||||
// (newProvider is called exactly once), and the run succeeds.
|
||||
func TestParseExecNotifyFlag(t *testing.T) {
|
||||
opts, _, err := parseExecArgs([]string{"--notify", "both", "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if opts.notifyMode != "both" || opts.noNotify {
|
||||
t.Fatalf("got mode=%q noNotify=%v", opts.notifyMode, opts.noNotify)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecNoNotifyFlag(t *testing.T) {
|
||||
opts, _, err := parseExecArgs([]string{"--no-notify", "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if !opts.noNotify {
|
||||
t.Fatal("expected noNotify=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecNotifyConflict(t *testing.T) {
|
||||
if _, _, err := parseExecArgs([]string{"--notify", "bell", "--no-notify", "hi"}); err == nil {
|
||||
t.Fatal("expected error for --notify with --no-notify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecNotifyInvalidValue(t *testing.T) {
|
||||
for _, arg := range [][]string{{"--notify", "buzz", "hi"}, {"--notify=loud", "hi"}} {
|
||||
if _, _, err := parseExecArgs(arg); err == nil {
|
||||
t.Fatalf("expected error for invalid notify value in %v", arg)
|
||||
}
|
||||
}
|
||||
// Valid values still parse.
|
||||
for _, mode := range []string{"off", "bell", "notify", "both"} {
|
||||
if _, _, err := parseExecArgs([]string{"--notify", mode, "hi"}); err != nil {
|
||||
t.Fatalf("valid --notify %s rejected: %v", mode, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecNotifyModeResolution(t *testing.T) {
|
||||
resolved := config.ResolvedConfig{Notify: config.NotifyConfig{Mode: "bell"}}
|
||||
if got := execNotifyMode(execOptions{}, resolved); got != "bell" {
|
||||
t.Fatalf("config passthrough got %q", got)
|
||||
}
|
||||
if got := execNotifyMode(execOptions{notifyMode: "both"}, resolved); got != "both" {
|
||||
t.Fatalf("flag override got %q", got)
|
||||
}
|
||||
if got := execNotifyMode(execOptions{noNotify: true}, resolved); got != "off" {
|
||||
t.Fatalf("--no-notify got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
|
||||
// Sanity: claude-opus-4.1 must genuinely be top-tier (no upgrade target), or
|
||||
// the scenario is meaningless.
|
||||
if _, ok := mustUpgradeTarget(t, "claude-opus-4.1"); ok {
|
||||
t.Skip("registry unexpectedly has an upgrade target for claude-opus-4.1")
|
||||
}
|
||||
|
||||
var providerModels []string
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec",
|
||||
"--allow-escalation",
|
||||
"--model", "claude-opus-4.1",
|
||||
"escalate please",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "claude-opus-4.1"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
cfg := execResolvedConfig()
|
||||
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
||||
cfg.Provider.Model = model
|
||||
cfg.MaxTurns = 3
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerModels = append(providerModels, profile.Model)
|
||||
return &escalatingExecProvider{escalateOnce: true}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
// The escalate_model call resolved to a no-target informational result, so the
|
||||
// loop never requested a switch and newProvider was built exactly once.
|
||||
if len(providerModels) != 1 {
|
||||
t.Fatalf("newProvider built for %#v, want exactly 1 (top-tier decline performs no switch)", providerModels)
|
||||
}
|
||||
if providerModels[0] != "claude-opus-4.1" {
|
||||
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -52,26 +51,9 @@ func validateExecToolFilters(options execOptions, registry *tools.Registry) erro
|
||||
return execUsageError{fmt.Sprintf("Tool cannot be both enabled and disabled: %s", name)}
|
||||
}
|
||||
}
|
||||
if options.useSpec {
|
||||
if disabled[specmode.SubmitToolName] {
|
||||
return execUsageError{"--use-spec requires submit_spec; remove it from --disabled-tools."}
|
||||
}
|
||||
if len(options.enabledTools) > 0 && !toolListContains(options.enabledTools, specmode.SubmitToolName) {
|
||||
return execUsageError{"--use-spec requires submit_spec; include it in --enabled-tools."}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toolListContains(names []string, want string) bool {
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error) {
|
||||
// Validate --auto first, regardless of --skip-permissions-unsafe, so an
|
||||
// invalid autonomy value is always rejected. (Previously the unsafe path
|
||||
|
||||
@@ -390,8 +390,6 @@ func streamJSONSideEffect(name string, registry *tools.Registry) string {
|
||||
return "shell"
|
||||
case tools.SideEffectNetwork:
|
||||
return "network"
|
||||
case tools.SideEffectNone:
|
||||
return "none"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// escalate_model is a control-only tool (SideEffectNone). The stream-json tool
|
||||
// call must report sideEffect "none", not "unknown", so automation sees the
|
||||
// promised value.
|
||||
func TestStreamJSONSideEffectReportsNoneForControlTool(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewEscalateModelTool())
|
||||
if got := streamJSONSideEffect("escalate_model", registry); got != "none" {
|
||||
t.Fatalf("streamJSONSideEffect(escalate_model) = %q, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
)
|
||||
|
||||
type specCommandOptions struct {
|
||||
json bool
|
||||
comment string
|
||||
commentProvided bool
|
||||
reason string
|
||||
reasonProvided bool
|
||||
}
|
||||
|
||||
type specCommandResult struct {
|
||||
Status string `json:"status"`
|
||||
SpecID string `json:"specId,omitempty"`
|
||||
SpecFilePath string `json:"specFilePath,omitempty"`
|
||||
DraftSessionID string `json:"draftSessionId,omitempty"`
|
||||
ImplementationSessionID string `json:"implementationSessionId,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Next string `json:"next,omitempty"`
|
||||
}
|
||||
|
||||
func runSpec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
command, target, options, help, err := parseSpecArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeSpecHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
store := deps.newSessionStore()
|
||||
draft, err := resolveSpecReviewTarget(store, target)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
switch command {
|
||||
case "show":
|
||||
return runSpecShow(store, draft, options, stdout, stderr)
|
||||
case "approve":
|
||||
return runSpecApprove(store, draft, options, stdout, stderr)
|
||||
case "reject":
|
||||
return runSpecReject(store, draft, options, stdout, stderr)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown spec command %q", command))
|
||||
}
|
||||
}
|
||||
|
||||
func parseSpecArgs(args []string) (string, string, specCommandOptions, bool, error) {
|
||||
options := specCommandOptions{}
|
||||
if len(args) == 0 {
|
||||
return "", "", options, false, execUsageError{"spec command required. Use `zero spec show <spec>`."}
|
||||
}
|
||||
command := ""
|
||||
target := ""
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return command, target, options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--comment":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return command, target, options, false, err
|
||||
}
|
||||
options.comment = value
|
||||
options.commentProvided = true
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--comment="):
|
||||
value, err := requiredInlineFlagValue(arg, "--comment")
|
||||
if err != nil {
|
||||
return command, target, options, false, err
|
||||
}
|
||||
options.comment = value
|
||||
options.commentProvided = true
|
||||
case arg == "--reason":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return command, target, options, false, err
|
||||
}
|
||||
options.reason = value
|
||||
options.reasonProvided = true
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--reason="):
|
||||
value, err := requiredInlineFlagValue(arg, "--reason")
|
||||
if err != nil {
|
||||
return command, target, options, false, err
|
||||
}
|
||||
options.reason = value
|
||||
options.reasonProvided = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return command, target, options, false, execUsageError{fmt.Sprintf("unknown spec flag %q", arg)}
|
||||
default:
|
||||
if command == "" {
|
||||
command = arg
|
||||
continue
|
||||
}
|
||||
if target == "" {
|
||||
target = arg
|
||||
continue
|
||||
}
|
||||
return command, target, options, false, execUsageError{fmt.Sprintf("unexpected spec argument %q", arg)}
|
||||
}
|
||||
}
|
||||
command = strings.TrimSpace(command)
|
||||
switch command {
|
||||
case "show", "approve", "reject":
|
||||
default:
|
||||
return command, target, options, false, execUsageError{fmt.Sprintf("unknown spec command %q", command)}
|
||||
}
|
||||
if strings.TrimSpace(target) == "" {
|
||||
return command, target, options, false, execUsageError{fmt.Sprintf("zero spec %s requires a spec id or draft session id", command)}
|
||||
}
|
||||
if options.commentProvided && command != "approve" {
|
||||
return command, target, options, false, execUsageError{"--comment is only valid for zero spec approve"}
|
||||
}
|
||||
if options.reasonProvided && command != "reject" {
|
||||
return command, target, options, false, execUsageError{"--reason is only valid for zero spec reject"}
|
||||
}
|
||||
return command, strings.TrimSpace(target), options, false, nil
|
||||
}
|
||||
|
||||
func resolveSpecReviewTarget(store *sessions.Store, target string) (sessions.Metadata, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return sessions.Metadata{}, fmt.Errorf("spec id or draft session id is required")
|
||||
}
|
||||
if sessions.ValidSessionID(target) {
|
||||
session, err := store.Get(target)
|
||||
if err != nil {
|
||||
return sessions.Metadata{}, err
|
||||
}
|
||||
if session != nil {
|
||||
if session.SpecID == "" || session.SpecFilePath == "" {
|
||||
return sessions.Metadata{}, fmt.Errorf("zero session has no recorded spec: %s", redact(target))
|
||||
}
|
||||
return *session, nil
|
||||
}
|
||||
}
|
||||
items, err := store.List()
|
||||
if err != nil {
|
||||
return sessions.Metadata{}, err
|
||||
}
|
||||
matches := []sessions.Metadata{}
|
||||
draftMatches := []sessions.Metadata{}
|
||||
for _, item := range items {
|
||||
if item.SpecID == target && item.SpecFilePath != "" {
|
||||
matches = append(matches, item)
|
||||
if item.SessionKind == sessions.SessionKindSpecDraft {
|
||||
draftMatches = append(draftMatches, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(draftMatches) == 1 {
|
||||
return draftMatches[0], nil
|
||||
}
|
||||
if len(draftMatches) > 1 {
|
||||
return sessions.Metadata{}, fmt.Errorf("zero spec id is ambiguous: %s; use the draft session id", redact(target))
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return sessions.Metadata{}, fmt.Errorf("zero spec not found: %s", redact(target))
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return sessions.Metadata{}, fmt.Errorf("zero spec id is ambiguous: %s; use the draft session id", redact(target))
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func runSpecShow(store *sessions.Store, draft sessions.Metadata, options specCommandOptions, stdout io.Writer, stderr io.Writer) int {
|
||||
body, path, err := specmode.LoadSpecFile(draft.Cwd, draft.SpecFilePath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
payload := map[string]any{
|
||||
"specId": draft.SpecID,
|
||||
"specFilePath": path,
|
||||
"draftSessionId": draft.SessionID,
|
||||
"specStatus": draft.SpecStatus,
|
||||
"content": body,
|
||||
}
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, body); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runSpecApprove(store *sessions.Store, draft sessions.Metadata, options specCommandOptions, stdout io.Writer, stderr io.Writer) int {
|
||||
if draft.SessionKind != sessions.SessionKindSpecDraft {
|
||||
return writeExecUsageError(stderr, "zero spec approve requires a spec-draft session")
|
||||
}
|
||||
if draft.SpecStatus == sessions.SpecStatusRejected {
|
||||
return writeExecUsageError(stderr, "zero spec approve cannot approve a rejected spec")
|
||||
}
|
||||
if draft.SpecStatus == sessions.SpecStatusApproved && draft.SpecImplSessionID != "" {
|
||||
return writeSpecResult(stdout, options, specCommandResult{
|
||||
Status: string(sessions.SpecStatusApproved),
|
||||
SpecID: draft.SpecID,
|
||||
SpecFilePath: draft.SpecFilePath,
|
||||
DraftSessionID: draft.SessionID,
|
||||
ImplementationSessionID: draft.SpecImplSessionID,
|
||||
Message: "Spec already approved.",
|
||||
Next: "zero exec --resume " + draft.SpecImplSessionID + ` "Start implementation"`,
|
||||
})
|
||||
}
|
||||
body, path, err := specmode.LoadSpecFile(draft.Cwd, draft.SpecFilePath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
prompt := specmode.ImplementationPrompt(body, path, draft.SessionID, options.comment)
|
||||
impl, _, err := store.EnsureSpecImplementation(sessions.EnsureSpecImplementationInput{
|
||||
Title: specImplementationTitle(draft),
|
||||
Cwd: draft.Cwd,
|
||||
ModelID: draft.ModelID,
|
||||
Provider: draft.Provider,
|
||||
RootSessionID: firstNonEmptyString(draft.RootSessionID, draft.SessionID),
|
||||
SpecID: draft.SpecID,
|
||||
SpecFilePath: path,
|
||||
SpecDraftModelID: draft.SpecDraftModelID,
|
||||
SpecDraftReasoning: draft.SpecDraftReasoning,
|
||||
SpecUserComment: options.comment,
|
||||
SpecSourceSessionID: draft.SessionID,
|
||||
Prompt: prompt,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
updated, _, err := store.RecordSpec(draft.SessionID, sessions.RecordSpecInput{
|
||||
SpecID: draft.SpecID,
|
||||
SpecFilePath: path,
|
||||
SpecStatus: sessions.SpecStatusApproved,
|
||||
SpecDraftModelID: draft.SpecDraftModelID,
|
||||
SpecDraftReasoning: draft.SpecDraftReasoning,
|
||||
SpecUserComment: options.comment,
|
||||
SpecImplSessionID: impl.SessionID,
|
||||
SpecSourceSessionID: draft.SessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return writeSpecResult(stdout, options, specCommandResult{
|
||||
Status: string(updated.SpecStatus),
|
||||
SpecID: updated.SpecID,
|
||||
SpecFilePath: updated.SpecFilePath,
|
||||
DraftSessionID: updated.SessionID,
|
||||
ImplementationSessionID: impl.SessionID,
|
||||
Message: "Spec approved.",
|
||||
Next: "zero exec --resume " + impl.SessionID + ` "Start implementation"`,
|
||||
})
|
||||
}
|
||||
|
||||
func runSpecReject(store *sessions.Store, draft sessions.Metadata, options specCommandOptions, stdout io.Writer, stderr io.Writer) int {
|
||||
if draft.SessionKind != sessions.SessionKindSpecDraft {
|
||||
return writeExecUsageError(stderr, "zero spec reject requires a spec-draft session")
|
||||
}
|
||||
if draft.SpecStatus == sessions.SpecStatusApproved && draft.SpecImplSessionID != "" {
|
||||
return writeExecUsageError(stderr, "zero spec reject cannot reject an approved spec with an implementation session")
|
||||
}
|
||||
updated, _, err := store.RecordSpec(draft.SessionID, sessions.RecordSpecInput{
|
||||
SpecID: draft.SpecID,
|
||||
SpecFilePath: draft.SpecFilePath,
|
||||
SpecStatus: sessions.SpecStatusRejected,
|
||||
SpecDraftModelID: draft.SpecDraftModelID,
|
||||
SpecDraftReasoning: draft.SpecDraftReasoning,
|
||||
SpecRejectReason: options.reason,
|
||||
SpecSourceSessionID: draft.SessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return writeSpecResult(stdout, options, specCommandResult{
|
||||
Status: string(updated.SpecStatus),
|
||||
SpecID: updated.SpecID,
|
||||
SpecFilePath: updated.SpecFilePath,
|
||||
DraftSessionID: updated.SessionID,
|
||||
Message: "Spec rejected.",
|
||||
Next: "Run zero exec --use-spec again with a revised task.",
|
||||
})
|
||||
}
|
||||
|
||||
func writeSpecResult(stdout io.Writer, options specCommandOptions, result specCommandResult) int {
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
lines := []string{result.Message}
|
||||
if result.SpecID != "" {
|
||||
lines = append(lines, " spec: "+redact(result.SpecID))
|
||||
}
|
||||
if result.SpecFilePath != "" {
|
||||
lines = append(lines, " path: "+redact(result.SpecFilePath))
|
||||
}
|
||||
if result.DraftSessionID != "" {
|
||||
lines = append(lines, " draft session: "+redact(result.DraftSessionID))
|
||||
}
|
||||
if result.ImplementationSessionID != "" {
|
||||
lines = append(lines, " implementation session: "+redact(result.ImplementationSessionID))
|
||||
}
|
||||
if result.Next != "" {
|
||||
lines = append(lines, "Next: "+redact(result.Next))
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, strings.Join(lines, "\n")); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func specImplementationTitle(draft sessions.Metadata) string {
|
||||
title := strings.TrimSpace(draft.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(draft.SpecID)
|
||||
}
|
||||
if title == "" {
|
||||
return "Spec implementation"
|
||||
}
|
||||
return title + " implementation"
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeSpecHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero spec show <spec-id|draft-session-id> [--json]
|
||||
zero spec approve <spec-id|draft-session-id> [--comment <text>] [--json]
|
||||
zero spec reject <spec-id|draft-session-id> [--reason <text>] [--json]
|
||||
|
||||
Commands:
|
||||
show Print the saved draft spec
|
||||
approve Mark a draft approved and create a spec implementation session
|
||||
reject Mark a draft rejected
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
--comment <text> Approval note to include in the implementation prompt
|
||||
--reason <text> Rejection reason
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
)
|
||||
|
||||
func TestRunSpecApproveCreatesImplementationSession(t *testing.T) {
|
||||
store, deps, draft := seedSpecDraft(t)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"spec", "approve", draft.SpecID, "--comment", "Keep changes focused.", "--json"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
var payload specCommandResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode approve json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Status != string(sessions.SpecStatusApproved) || payload.ImplementationSessionID == "" {
|
||||
t.Fatalf("unexpected approve payload: %#v", payload)
|
||||
}
|
||||
updatedDraft, err := store.Get(draft.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updatedDraft.SpecStatus != sessions.SpecStatusApproved || updatedDraft.SpecImplSessionID != payload.ImplementationSessionID {
|
||||
t.Fatalf("draft metadata not updated: %#v", updatedDraft)
|
||||
}
|
||||
impl, err := store.Get(payload.ImplementationSessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if impl == nil || impl.SessionKind != sessions.SessionKindSpecImpl || impl.SpecSourceSessionID != draft.SessionID {
|
||||
t.Fatalf("unexpected impl session: %#v", impl)
|
||||
}
|
||||
events, err := store.ReadEvents(payload.ImplementationSessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Type != sessions.EventMessage {
|
||||
t.Fatalf("implementation events = %#v", events)
|
||||
}
|
||||
if !strings.Contains(string(events[0].Payload), "Keep changes focused.") || !strings.Contains(string(events[0].Payload), "# Goal") {
|
||||
t.Fatalf("implementation prompt missing approved spec context: %s", string(events[0].Payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSpecApproveIsIdempotent(t *testing.T) {
|
||||
store, deps, draft := seedSpecDraft(t)
|
||||
|
||||
var first bytes.Buffer
|
||||
if exitCode := runWithDeps([]string{"spec", "approve", draft.SessionID, "--json"}, &first, &bytes.Buffer{}, deps); exitCode != exitSuccess {
|
||||
t.Fatalf("first approve exit = %d", exitCode)
|
||||
}
|
||||
var firstPayload specCommandResult
|
||||
if err := json.Unmarshal(first.Bytes(), &firstPayload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var second bytes.Buffer
|
||||
if exitCode := runWithDeps([]string{"spec", "approve", draft.SpecID, "--json"}, &second, &bytes.Buffer{}, deps); exitCode != exitSuccess {
|
||||
t.Fatalf("second approve exit = %d", exitCode)
|
||||
}
|
||||
var secondPayload specCommandResult
|
||||
if err := json.Unmarshal(second.Bytes(), &secondPayload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondPayload.ImplementationSessionID != firstPayload.ImplementationSessionID {
|
||||
t.Fatalf("approve created a second implementation session: first=%q second=%q", firstPayload.ImplementationSessionID, secondPayload.ImplementationSessionID)
|
||||
}
|
||||
items, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
implCount := 0
|
||||
for _, item := range items {
|
||||
if item.SessionKind == sessions.SessionKindSpecImpl {
|
||||
implCount++
|
||||
}
|
||||
}
|
||||
if implCount != 1 {
|
||||
t.Fatalf("implementation session count = %d, want 1", implCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSpecRejectMarksDraftRejected(t *testing.T) {
|
||||
store, deps, draft := seedSpecDraft(t)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"spec", "reject", draft.SpecID, "--reason=Needs narrower scope"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
updated, err := store.Get(draft.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.SpecStatus != sessions.SpecStatusRejected || updated.SpecRejectReason != "Needs narrower scope" {
|
||||
t.Fatalf("draft metadata not rejected: %#v", updated)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Spec rejected.") {
|
||||
t.Fatalf("stdout = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpecRejectsCommandSpecificFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "comment on show",
|
||||
args: []string{"show", "draft", "--comment", "ship it"},
|
||||
want: "--comment is only valid for zero spec approve",
|
||||
},
|
||||
{
|
||||
name: "reason on approve",
|
||||
args: []string{"approve", "draft", "--reason", "too broad"},
|
||||
want: "--reason is only valid for zero spec reject",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, _, _, err := parseSpecArgs(tc.args)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected %q validation, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSpecShowPrintsSavedDraft(t *testing.T) {
|
||||
_, deps, draft := seedSpecDraft(t)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"spec", "show", draft.SessionID}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "# Goal") || !strings.Contains(stdout.String(), "Add review flow.") {
|
||||
t.Fatalf("stdout = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func seedSpecDraft(t *testing.T) (*sessions.Store, appDeps, sessions.Metadata) {
|
||||
t.Helper()
|
||||
workspaceRoot := t.TempDir()
|
||||
saved, err := specmode.SaveDraft(specmode.SaveOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Title: "Review Flow",
|
||||
Plan: "# Goal\n\nAdd review flow.",
|
||||
Now: fixedCLISpecTime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
storeRoot := t.TempDir()
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: storeRoot, Now: fixedCLISpecTime})
|
||||
draft, err := store.Create(sessions.CreateInput{
|
||||
SessionKind: sessions.SessionKindSpecDraft,
|
||||
Title: "Review flow",
|
||||
Cwd: workspaceRoot,
|
||||
ModelID: "test-model",
|
||||
Provider: "test",
|
||||
SpecID: saved.ID,
|
||||
SpecFilePath: saved.Path,
|
||||
SpecStatus: sessions.SpecStatusDraft,
|
||||
SpecDraftModelID: "test-model",
|
||||
SpecDraftReasoning: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := appDeps{
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(storeRoot)
|
||||
})
|
||||
return store, deps, draft
|
||||
}
|
||||
|
||||
func fixedCLISpecTime() time.Time {
|
||||
return time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
+18
-51
@@ -21,14 +21,11 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
fs.SetOutput(stderr)
|
||||
snapshot := fs.Bool("snapshot", false, "render a single frame to stdout and exit (no TTY)")
|
||||
page := fs.String("page", "home", "snapshot page: home|chat")
|
||||
variant := fs.Int("variant", 0, "color theme 0-5 (0 ZERO, 1 Phosphor, 2 Cyan, 3 Sage, 4 Violet, 5 Mono)")
|
||||
variant := fs.Int("variant", 1, "color theme 1-5 (1 Phosphor, 2 Cyan, 3 Sage, 4 Violet, 5 Mono)")
|
||||
light := fs.Bool("light", false, "use the light variant for the snapshot")
|
||||
perm := fs.Bool("perm", false, "show the centered permission modal in the chat snapshot")
|
||||
boot := fs.Int("boot", -1, "render the boot splash at the given animation frame")
|
||||
stream := fs.Bool("stream", false, "show a streaming assistant response in the chat snapshot")
|
||||
jsonMode := fs.Bool("json", false, "render the chat snapshot in JSON mode (TEXT/JSON toggle)")
|
||||
sessionsDrawer := fs.Bool("sessions", false, "show the sessions drawer in the chat snapshot")
|
||||
tool := fs.String("tool", "", "show a single tool card in the chat snapshot: diff|read|bash|grep")
|
||||
frameN := fs.Int("frame", 0, "animation frame for the chat snapshot (spinner)")
|
||||
width := fs.Int("width", 100, "snapshot width")
|
||||
height := fs.Int("height", 30, "snapshot height")
|
||||
skipUnsafe := fs.Bool("skip-permissions-unsafe", false, "launch in unsafe permission mode (enables the ! shell escape)")
|
||||
@@ -37,10 +34,16 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
}
|
||||
|
||||
if *snapshot {
|
||||
v := *variant // theme index directly (0 ZERO, 1 Phosphor, …)
|
||||
v := *variant - 1
|
||||
if v < 0 || v >= len(zeroline.Themes) {
|
||||
v = 0
|
||||
}
|
||||
if *boot >= 0 {
|
||||
if _, err := fmt.Fprintln(stdout, zeroline.RenderBoot(v, !*light, *boot, *width, *height)); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
hdr := zeroline.Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"}
|
||||
var frame string
|
||||
if *page == "chat" {
|
||||
@@ -48,29 +51,19 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
|
||||
Rows: []zeroline.Row{
|
||||
{Kind: "user", Text: "refactor internal/agent/loop.go to extract tool execution"},
|
||||
{Kind: "tool", Tool: "grep", Text: "internal/agent", Status: "ok", Detail: "internal/agent/loop.go:88:\tfor i, call := range reply.ToolCalls {\ninternal/agent/loop.go:90:\t\tgo func() {"},
|
||||
{Kind: "tool", Tool: "read_file", Text: "internal/agent/loop.go", Status: "ok", Detail: "func (l *Loop) run(ctx context.Context) error {\n\tfor step := 0; step < l.MaxSteps; step++ {\n\t\treply, _, err := l.model.Complete(ctx, msgs, l.tools)"},
|
||||
{Kind: "tool", Tool: "edit_file", Text: "internal/agent/exec.go", Status: "ok", Detail: " func (l *Loop) run(ctx context.Context) error {\n-\tswitch t := call.Tool.(type) {\n-\tcase ReadFileTool: out, err = l.readFile(ctx, t)\n+\tout, err := l.exec.Dispatch(call)"},
|
||||
{Kind: "tool", Tool: "bash", Text: "go test ./...", Status: "ok", Detail: "ok github.com/zero-dev/zero/internal/agent\t0.061s\nok github.com/zero-dev/zero/internal/tools\t0.140s"},
|
||||
{Kind: "toolcall", Tool: "list_directory", Detail: "internal/agent"},
|
||||
{Kind: "toolresult", Tool: "list_directory", Status: "ok", Detail: "Contents of internal/agent:\n\nloop.go\ntypes.go\nloop_test.go"},
|
||||
{Kind: "toolcall", Tool: "read_file", Detail: "internal/agent/loop.go"},
|
||||
{Kind: "toolresult", Tool: "read_file", Status: "ok", Detail: "File: internal/agent/loop.go (164 lines)\n\n118 | func (l *Loop) run(ctx context.Context) error {"},
|
||||
{Kind: "toolcall", Tool: "edit_file", Detail: "exec.go (new) · loop.go"},
|
||||
{Kind: "toolresult", Tool: "edit_file", Status: "ok", Detail: "--- a/internal/agent/loop.go\n+++ b/internal/agent/exec.go\n@@ -141,6 +141,3 @@\n-\tswitch t := call.Tool.(type) {\n-\tcase ReadFileTool: out, err = l.readFile(ctx, t)\n+\tout, err := l.exec.Dispatch(call)"},
|
||||
{Kind: "assistant", Text: "Done. Extracted a `ToolExecutor`:\n\n```go\nfunc (e *ToolExecutor) Dispatch(c Call) (Out, error) {\n\treturn e.route(c)\n}\n```\n\nThe switch in loop.go now delegates to one call. Tests pass."},
|
||||
{Kind: "final", Text: "Extracted tool dispatch into a ToolExecutor; loop.go now delegates. go test ./... is green."},
|
||||
{Kind: "done", Text: "4 tools · 1,284 tok · $0.04", Status: "ok"},
|
||||
},
|
||||
Input: "add a test for the new ToolExecutor",
|
||||
Spin: *frameN,
|
||||
}
|
||||
if *tool != "" {
|
||||
cd.Rows = toolSnapshotRows(*tool)
|
||||
Input: "❯ ",
|
||||
}
|
||||
if *perm {
|
||||
cd.Perm = &zeroline.Perm{Tool: "edit_file", Risk: "medium", Reason: "writes internal/agent/exec.go and loop.go", Summary: "write"}
|
||||
}
|
||||
if *jsonMode {
|
||||
cd.JSONMode = true
|
||||
}
|
||||
if *sessionsDrawer {
|
||||
cd.Drawer = &zeroline.Drawer{Sessions: zeroline.DefaultSessions()}
|
||||
}
|
||||
if *stream {
|
||||
cd.Rows = cd.Rows[:len(cd.Rows)-1] // drop the final assistant row
|
||||
cd.Working = true
|
||||
@@ -79,11 +72,9 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
}
|
||||
frame = zeroline.RenderChat(cd)
|
||||
} else {
|
||||
frame = zeroline.RenderChat(zeroline.ChatData{
|
||||
frame = zeroline.RenderHome(zeroline.HomeData{
|
||||
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
|
||||
Input: "describe a task for zero…",
|
||||
Chips: zeroline.DefaultChips(),
|
||||
ChipIndex: 0,
|
||||
Input: "❯ message zero — / commands · @ files · ! bash",
|
||||
})
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, frame); err != nil {
|
||||
@@ -98,27 +89,3 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
}
|
||||
return runInteractiveTUIWithSkin(stderr, deps, "zeroline", permissionMode)
|
||||
}
|
||||
|
||||
// toolSnapshotRows builds a chat focused on a single tool card type, for
|
||||
// `zeroline --snapshot --tool <kind>`.
|
||||
func toolSnapshotRows(kind string) []zeroline.Row {
|
||||
user := zeroline.Row{Kind: "user", Text: "show me the " + kind + " tool card"}
|
||||
var tool zeroline.Row
|
||||
switch kind {
|
||||
case "diff":
|
||||
tool = zeroline.Row{Kind: "tool", Tool: "edit_file", Text: "internal/cli/root.go", Status: "ok",
|
||||
Detail: " \tfs.IntVar(&o.MaxSteps, \"max-steps\", 50, \"agent step cap\")\n+\tfs.BoolVar(&o.ShowVersion, \"version\", false, \"print version and exit\")\n \n+\tif o.ShowVersion {\n+\t\tfmt.Fprintln(o.Stdout, \"zero \"+buildinfo.Version)\n+\t}"}
|
||||
case "read":
|
||||
tool = zeroline.Row{Kind: "tool", Tool: "read_file", Text: "internal/agent/loop.go", Status: "ok",
|
||||
Detail: "File: internal/agent/loop.go (164 lines)\n\n 1 | func (l *Loop) Run(ctx context.Context, task string) error {\n 2 | \tl.emit(Event{Type: EventStart})\n 3 | \tmsgs := l.seed(task)\n 4 | \tfor step := 0; step < l.MaxSteps; step++ {\n 5 | \t\treply, usage, err := l.model.Complete(ctx, msgs, l.tools)"}
|
||||
case "bash":
|
||||
tool = zeroline.Row{Kind: "tool", Tool: "bash", Text: "go test ./internal/cli/...", Status: "ok",
|
||||
Detail: "ok \tgithub.com/zero-dev/zero/internal/cli\t0.214s"}
|
||||
case "grep":
|
||||
tool = zeroline.Row{Kind: "tool", Tool: "grep", Text: "internal/cli", Status: "ok",
|
||||
Detail: "internal/cli/root.go:41:fs := flag.NewFlagSet(\"zero\", flag.ContinueOnError)\ninternal/cli/root.go:47:fs.StringVar(&o.Model, \"model\", env(\"ZERO_MODEL\"), \"model id\")"}
|
||||
default:
|
||||
tool = zeroline.Row{Kind: "tool", Tool: kind, Text: "(unknown tool kind: " + kind + ")", Status: "error", Detail: "supported: diff|read|bash|grep"}
|
||||
}
|
||||
return []zeroline.Row{user, tool, {Kind: "done", Text: "1 tool · 214 tok", Status: "ok"}}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestZerolineSnapshotToolFlag(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"diff": {"edit_file", "ShowVersion"},
|
||||
"read": {"read_file", "Loop"},
|
||||
"bash": {"bash", "exit 0"},
|
||||
"grep": {"grep", "matches"},
|
||||
}
|
||||
for kind, wants := range cases {
|
||||
var out, errb bytes.Buffer
|
||||
code := runZeroline([]string{"--snapshot", "--page", "chat", "--tool", kind, "--width", "100", "--height", "20"}, &out, &errb, appDeps{})
|
||||
if code != 0 {
|
||||
t.Fatalf("--tool %s: exit %d, stderr=%s", kind, code, errb.String())
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(out.String(), w) {
|
||||
t.Errorf("--tool %s snapshot missing %q", kind, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZerolineSnapshotModes(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--snapshot", "--page", "home"},
|
||||
{"--snapshot", "--page", "chat"},
|
||||
{"--snapshot", "--page", "chat", "--json"},
|
||||
{"--snapshot", "--page", "chat", "--sessions"},
|
||||
{"--snapshot", "--page", "chat", "--perm"},
|
||||
{"--snapshot", "--page", "chat", "--stream", "--frame", "3"},
|
||||
} {
|
||||
var out, errb bytes.Buffer
|
||||
full := append(args, "--width", "100", "--height", "24")
|
||||
if code := runZeroline(full, &out, &errb, appDeps{}); code != 0 {
|
||||
t.Fatalf("%v: exit %d, stderr=%s", args, code, errb.String())
|
||||
}
|
||||
if strings.TrimSpace(out.String()) == "" {
|
||||
t.Errorf("%v produced empty output", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
@@ -67,20 +66,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.maxAutonomy %q: expected low, medium, or high", maxAutonomy)
|
||||
}
|
||||
}
|
||||
if mode := strings.TrimSpace(cfg.Notify.Mode); mode != "" {
|
||||
switch notify.Mode(mode) {
|
||||
case notify.ModeOff, notify.ModeBell, notify.ModeNotify, notify.ModeBoth:
|
||||
default:
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid notify.mode %q: expected off, bell, notify, or both", mode)
|
||||
}
|
||||
}
|
||||
if focusMode := strings.TrimSpace(cfg.Notify.FocusMode); focusMode != "" {
|
||||
switch notify.FocusMode(focusMode) {
|
||||
case notify.FocusUnfocused, notify.FocusAlways, notify.FocusFocused:
|
||||
default:
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focusMode)
|
||||
}
|
||||
}
|
||||
|
||||
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
|
||||
if err != nil {
|
||||
@@ -94,7 +79,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
}, nil
|
||||
}
|
||||
@@ -143,12 +127,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
dst.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
|
||||
dst.Notify.Mode = mode
|
||||
}
|
||||
if focusMode := strings.TrimSpace(src.Notify.FocusMode); focusMode != "" {
|
||||
dst.Notify.FocusMode = focusMode
|
||||
}
|
||||
if src.Tools.deferThresholdSet {
|
||||
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
|
||||
dst.Tools.deferThresholdSet = true
|
||||
@@ -173,12 +151,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
dst.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
|
||||
dst.Notify.Mode = mode
|
||||
}
|
||||
if focusMode := strings.TrimSpace(src.Notify.FocusMode); focusMode != "" {
|
||||
dst.Notify.FocusMode = focusMode
|
||||
}
|
||||
if src.Tools.deferThresholdSet {
|
||||
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
|
||||
dst.Tools.deferThresholdSet = true
|
||||
@@ -487,12 +459,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if maxAutonomy := strings.TrimSpace(overrides.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
cfg.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
if mode := strings.TrimSpace(overrides.Notify.Mode); mode != "" {
|
||||
cfg.Notify.Mode = mode
|
||||
}
|
||||
if focusMode := strings.TrimSpace(overrides.Notify.FocusMode); focusMode != "" {
|
||||
cfg.Notify.FocusMode = focusMode
|
||||
}
|
||||
if overrides.Tools.deferThresholdSet || overrides.Tools.DeferThreshold != 0 {
|
||||
cfg.Tools.DeferThreshold = overrides.Tools.DeferThreshold
|
||||
cfg.Tools.deferThresholdSet = true
|
||||
|
||||
@@ -1284,42 +1284,6 @@ func TestResolveSandboxMaxAutonomyValidResolves(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyValid(t *testing.T) {
|
||||
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Notify.Mode != "both" || resolved.Notify.FocusMode != "always" {
|
||||
t.Fatalf("got %+v", resolved.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyInvalidMode(t *testing.T) {
|
||||
path := writeConfig(t, `{"notify":{"mode":"buzz"}}`)
|
||||
if _, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyInvalidFocusMode(t *testing.T) {
|
||||
path := writeConfig(t, `{"notify":{"focusMode":"sideways"}}`)
|
||||
if _, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.focusMode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyDefaultEmpty(t *testing.T) {
|
||||
path := writeConfig(t, `{}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Notify.Mode != "" || resolved.Notify.FocusMode != "" {
|
||||
t.Fatalf("unset notify should be empty, got %+v", resolved.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -58,11 +58,6 @@ type SandboxConfig struct {
|
||||
MaxAutonomy string `json:"maxAutonomy,omitempty"`
|
||||
}
|
||||
|
||||
type NotifyConfig struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
FocusMode string `json:"focusMode,omitempty"`
|
||||
}
|
||||
|
||||
type ToolsConfig struct {
|
||||
DeferThreshold int `json:"deferThreshold,omitempty"`
|
||||
deferThresholdSet bool
|
||||
@@ -99,7 +94,6 @@ type FileConfig struct {
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
@@ -118,7 +112,6 @@ type Overrides struct {
|
||||
MaxTurns int
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
}
|
||||
|
||||
@@ -129,7 +122,6 @@ type ResolvedConfig struct {
|
||||
MaxTurns int
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
}
|
||||
|
||||
@@ -155,7 +147,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
MaxTurns int `json:"maxTurns"`
|
||||
MCP MCPConfig `json:"mcp"`
|
||||
Sandbox SandboxConfig `json:"sandbox"`
|
||||
Notify NotifyConfig `json:"notify"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
@@ -170,7 +161,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.MaxTurns = raw.MaxTurns
|
||||
cfg.MCP = raw.MCP
|
||||
cfg.Sandbox = raw.Sandbox
|
||||
cfg.Notify = raw.Notify
|
||||
cfg.Tools = raw.Tools
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
|
||||
@@ -51,12 +51,10 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
defaultEffort ReasoningEffort
|
||||
patterns []string
|
||||
deprecation *DeprecationRule
|
||||
upgradeTarget string
|
||||
}{
|
||||
"claude-sonnet-4.5": {
|
||||
defaultEffort: ReasoningEffortMedium,
|
||||
patterns: []string{`(?i)^sonnet[^a-z0-9]*4[.\s]?5$`},
|
||||
upgradeTarget: "claude-opus-4.1",
|
||||
},
|
||||
"claude-opus-4.1": {
|
||||
defaultEffort: ReasoningEffortHigh,
|
||||
@@ -65,7 +63,6 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
"claude-haiku-4.5": {
|
||||
defaultEffort: ReasoningEffortLow,
|
||||
patterns: []string{`(?i)^haiku[^a-z0-9]*4[.\s]?5$`},
|
||||
upgradeTarget: "claude-sonnet-4.5",
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
defaultEffort: ReasoningEffortMedium,
|
||||
@@ -73,19 +70,6 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
defaultEffort: ReasoningEffortLow,
|
||||
upgradeTarget: "gemini-2.5-pro",
|
||||
},
|
||||
"gemini-2.5-flash-lite": {
|
||||
upgradeTarget: "gemini-2.5-flash",
|
||||
},
|
||||
"gpt-4.1-mini": {
|
||||
upgradeTarget: "gpt-4.1",
|
||||
},
|
||||
"gpt-4.1-nano": {
|
||||
upgradeTarget: "gpt-4.1-mini",
|
||||
},
|
||||
"gpt-4o-mini": {
|
||||
upgradeTarget: "gpt-4o",
|
||||
},
|
||||
"gpt-4-turbo": {
|
||||
deprecation: &DeprecationRule{
|
||||
@@ -116,9 +100,6 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
if extra.deprecation != nil {
|
||||
entries[index].Deprecation = extra.deprecation.Clone()
|
||||
}
|
||||
if extra.upgradeTarget != "" {
|
||||
entries[index].UpgradeTargetID = extra.upgradeTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -216,49 +216,6 @@ func reasoningEffortAllowedIn(efforts []ReasoningEffort, want ReasoningEffort) b
|
||||
return false
|
||||
}
|
||||
|
||||
func TestDefaultRegistryUpgradeTargets(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
// Seeded escalation chains across the concrete catalog ids.
|
||||
chains := map[string]string{
|
||||
"claude-haiku-4.5": "claude-sonnet-4.5",
|
||||
"claude-sonnet-4.5": "claude-opus-4.1",
|
||||
"gpt-4.1-mini": "gpt-4.1",
|
||||
"gpt-4.1-nano": "gpt-4.1-mini",
|
||||
"gpt-4o-mini": "gpt-4o",
|
||||
"gemini-2.5-flash-lite": "gemini-2.5-flash",
|
||||
"gemini-2.5-flash": "gemini-2.5-pro",
|
||||
}
|
||||
for source, want := range chains {
|
||||
target, ok := registry.UpgradeTarget(source)
|
||||
if !ok {
|
||||
t.Fatalf("UpgradeTarget(%q) returned no target, want %q", source, want)
|
||||
}
|
||||
if target.ID != want {
|
||||
t.Fatalf("UpgradeTarget(%q) = %q, want %q", source, target.ID, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Top-tier models declare no escalation target.
|
||||
for _, topTier := range []string{"claude-opus-4.1", "gpt-4.1", "gemini-2.5-pro"} {
|
||||
if target, ok := registry.UpgradeTarget(topTier); ok {
|
||||
t.Fatalf("top-tier model %q should have no upgrade target, got %q", topTier, target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// The seeded field is present on the decorated entry, not only via the method.
|
||||
haiku, err := registry.Require("claude-haiku-4.5")
|
||||
if err != nil {
|
||||
t.Fatalf("Require(claude-haiku-4.5) returned error: %v", err)
|
||||
}
|
||||
if haiku.UpgradeTargetID != "claude-sonnet-4.5" {
|
||||
t.Fatalf("claude-haiku-4.5 UpgradeTargetID = %q, want claude-sonnet-4.5", haiku.UpgradeTargetID)
|
||||
}
|
||||
}
|
||||
|
||||
func containsModelID(models []ModelEntry, id string) bool {
|
||||
for _, model := range models {
|
||||
if model.ID == id {
|
||||
|
||||
@@ -96,11 +96,7 @@ type ModelEntry struct {
|
||||
MatchPatterns []string
|
||||
// Deprecation, when set, redirects this model to a replacement.
|
||||
Deprecation *DeprecationRule
|
||||
// UpgradeTargetID, when set, names the stronger model this one escalates to
|
||||
// during mid-run escalation. Empty means no escalation target (e.g. top-tier
|
||||
// models). Resolved and availability-checked by Registry.UpgradeTarget.
|
||||
UpgradeTargetID string
|
||||
Description string
|
||||
Description string
|
||||
}
|
||||
|
||||
// DeprecationRule describes how a deprecated model is phased out and what to use
|
||||
@@ -346,18 +342,6 @@ func NewRegistry(entries []ModelEntry) (Registry, error) {
|
||||
return Registry{}, fmt.Errorf("model %q deprecation fallback %q does not resolve to a known model", entry.ID, fallbackID)
|
||||
}
|
||||
}
|
||||
// A non-empty UpgradeTargetID must resolve to a known model. Otherwise
|
||||
// UpgradeTarget would silently disable escalation at runtime (a catalog typo)
|
||||
// instead of failing loudly here — mirroring the deprecation fallback check.
|
||||
for _, entry := range registry.models {
|
||||
targetID := strings.TrimSpace(entry.UpgradeTargetID)
|
||||
if targetID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := registry.Get(targetID); !ok {
|
||||
return Registry{}, fmt.Errorf("model %q upgrade target %q does not resolve to a known model", entry.ID, targetID)
|
||||
}
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package modelregistry
|
||||
|
||||
import "strings"
|
||||
|
||||
// UpgradeTarget resolves the stronger model that id escalates to during mid-run
|
||||
// escalation. It resolves id to a concrete entry, reads its UpgradeTargetID,
|
||||
// resolves THAT to a concrete entry, and returns it only when the target is
|
||||
// available and not deprecated. It returns (_, false) when id is unknown, has no
|
||||
// configured target, or the target resolves to an unavailable/deprecated model.
|
||||
// Pure and registry-only: applies no deprecation fallback redirection.
|
||||
func (registry Registry) UpgradeTarget(id string) (ModelEntry, bool) {
|
||||
source, ok := registry.Resolve(id)
|
||||
if !ok {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
targetID := strings.TrimSpace(source.UpgradeTargetID)
|
||||
if targetID == "" {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
target, ok := registry.Resolve(targetID)
|
||||
if !ok {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
if target.Status == ModelStatusDeprecated {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
return target, true
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// upgradeEntry builds a minimal valid active entry with an upgrade target set.
|
||||
func upgradeEntry(id, alias, upgradeTargetID string) ModelEntry {
|
||||
entry := mkEntry(id, alias)
|
||||
entry.UpgradeTargetID = upgradeTargetID
|
||||
return entry
|
||||
}
|
||||
|
||||
func upgradeTestRegistry(t *testing.T) Registry {
|
||||
t.Helper()
|
||||
haiku := upgradeEntry("claude-haiku-4-5", "haiku-4.5", "claude-sonnet-4-5")
|
||||
sonnet := upgradeEntry("claude-sonnet-4-5", "sonnet-4.5", "claude-opus-4-1")
|
||||
opus := mkEntry("claude-opus-4-1", "opus-4.1") // top-tier: no UpgradeTargetID
|
||||
|
||||
// A model whose target points at a deprecated entry must not escalate.
|
||||
legacy := upgradeEntry("legacy-mini", "legacy-m", "legacy-retired")
|
||||
retired := mkEntry("legacy-retired", "legacy-r")
|
||||
retired.Status = ModelStatusDeprecated
|
||||
retired.Deprecation = &DeprecationRule{FallbackID: "claude-opus-4-1", WarningMsg: "retired"}
|
||||
|
||||
reg, err := NewRegistry([]ModelEntry{haiku, sonnet, opus, legacy, retired})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry returned error: %v", err)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestUpgradeTargetResolvesConcreteEntry(t *testing.T) {
|
||||
reg := upgradeTestRegistry(t)
|
||||
|
||||
target, ok := reg.UpgradeTarget("claude-haiku-4-5")
|
||||
if !ok {
|
||||
t.Fatal("haiku should escalate to its upgrade target")
|
||||
}
|
||||
if target.ID != "claude-sonnet-4-5" {
|
||||
t.Fatalf("UpgradeTarget(haiku) = %q, want claude-sonnet-4-5", target.ID)
|
||||
}
|
||||
|
||||
// Resolution should also work through an alias for the source model.
|
||||
if target, ok := reg.UpgradeTarget("sonnet-4.5"); !ok || target.ID != "claude-opus-4-1" {
|
||||
t.Fatalf("UpgradeTarget(sonnet alias) = %q/%v, want claude-opus-4-1/true", target.ID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeTargetTopTierHasNone(t *testing.T) {
|
||||
reg := upgradeTestRegistry(t)
|
||||
if target, ok := reg.UpgradeTarget("claude-opus-4-1"); ok {
|
||||
t.Fatalf("top-tier model should have no upgrade target, got %q", target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeTargetUnknownSource(t *testing.T) {
|
||||
reg := upgradeTestRegistry(t)
|
||||
if _, ok := reg.UpgradeTarget("not-a-real-model"); ok {
|
||||
t.Fatal("unknown source model should not yield an upgrade target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeTargetDeprecatedTargetRejected(t *testing.T) {
|
||||
reg := upgradeTestRegistry(t)
|
||||
if target, ok := reg.UpgradeTarget("legacy-mini"); ok {
|
||||
t.Fatalf("deprecated target should be rejected, got %q", target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRegistryRejectsDanglingUpgradeTarget(t *testing.T) {
|
||||
// A non-empty UpgradeTargetID that does not resolve to a known model is a
|
||||
// catalog typo; NewRegistry must fail loudly rather than silently disabling
|
||||
// escalation at runtime (mirrors the deprecation-fallback validation).
|
||||
dangling := upgradeEntry("dangling-mini", "dangling-m", "does-not-exist")
|
||||
_, err := NewRegistry([]ModelEntry{dangling})
|
||||
if err == nil {
|
||||
t.Fatal("NewRegistry should reject an unresolvable upgrade target")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "upgrade target") {
|
||||
t.Fatalf("error %q should mention the upgrade target", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeTargetReturnsIndependentCopy(t *testing.T) {
|
||||
reg := upgradeTestRegistry(t)
|
||||
target, ok := reg.UpgradeTarget("claude-haiku-4-5")
|
||||
if !ok {
|
||||
t.Fatal("haiku should escalate")
|
||||
}
|
||||
target.Aliases = append(target.Aliases, "mutated")
|
||||
again, _ := reg.UpgradeTarget("claude-haiku-4-5")
|
||||
for _, alias := range again.Aliases {
|
||||
if alias == "mutated" {
|
||||
t.Fatal("UpgradeTarget must return a defensive copy, not a shared entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Package notify emits dep-free terminal notifications (BEL and/or OSC-9
|
||||
// desktop notifications) when Zero finishes a turn or needs user input.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Mode selects the notification mechanism.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeOff Mode = "off"
|
||||
ModeBell Mode = "bell"
|
||||
ModeNotify Mode = "notify" // OSC-9 desktop notification
|
||||
ModeBoth Mode = "both"
|
||||
)
|
||||
|
||||
// FocusMode gates emission on terminal focus (a TUI concept).
|
||||
type FocusMode string
|
||||
|
||||
const (
|
||||
FocusUnfocused FocusMode = "unfocused" // default: only when terminal is NOT focused
|
||||
FocusAlways FocusMode = "always"
|
||||
FocusFocused FocusMode = "focused"
|
||||
)
|
||||
|
||||
// Event is the moment that triggered a notification.
|
||||
type Event int
|
||||
|
||||
const (
|
||||
Completion Event = iota
|
||||
AwaitingInput
|
||||
)
|
||||
|
||||
// Config is the resolved notifier policy. Zero value (Mode=="") is silent.
|
||||
type Config struct {
|
||||
Mode Mode
|
||||
FocusMode FocusMode
|
||||
}
|
||||
|
||||
const maxMessageLen = 120
|
||||
|
||||
// Notifier emits notifications to w according to cfg. Safe for concurrent use.
|
||||
type Notifier struct {
|
||||
w io.Writer
|
||||
cfg Config // immutable after New; reads outside the lock are safe
|
||||
|
||||
mu sync.Mutex
|
||||
focused bool
|
||||
}
|
||||
|
||||
// New returns a Notifier. focused defaults to false so a headless caller (no
|
||||
// focus signal) still emits under the default "unfocused" focus mode; an
|
||||
// interactive caller should call SetFocused(true) at launch.
|
||||
func New(w io.Writer, cfg Config) *Notifier {
|
||||
return &Notifier{w: w, cfg: cfg}
|
||||
}
|
||||
|
||||
// SetFocused records the terminal focus state (TUI FocusMsg/BlurMsg).
|
||||
func (n *Notifier) SetFocused(focused bool) {
|
||||
n.mu.Lock()
|
||||
n.focused = focused
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// Notify emits a notification for event if policy allows. message is the OSC-9
|
||||
// body (ignored for bell). Write errors are intentionally ignored — a failed
|
||||
// notification must never disrupt the run.
|
||||
func (n *Notifier) Notify(event Event, message string) {
|
||||
if n.w == nil || n.cfg.Mode == ModeOff || n.cfg.Mode == "" {
|
||||
return
|
||||
}
|
||||
seq := sequence(n.cfg.Mode, message)
|
||||
if seq == "" {
|
||||
return
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if !shouldEmit(n.cfg, event, n.focused) {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(n.w, seq)
|
||||
}
|
||||
|
||||
// DefaultMessage is the generic OSC-9 body for an event (no prompt content).
|
||||
func DefaultMessage(event Event) string {
|
||||
if event == AwaitingInput {
|
||||
return "Zero: needs input"
|
||||
}
|
||||
return "Zero: ready"
|
||||
}
|
||||
|
||||
func shouldEmit(cfg Config, _ Event, focused bool) bool {
|
||||
if cfg.Mode == ModeOff || cfg.Mode == "" {
|
||||
return false
|
||||
}
|
||||
switch cfg.FocusMode {
|
||||
case FocusAlways:
|
||||
return true
|
||||
case FocusFocused:
|
||||
return focused
|
||||
default: // FocusUnfocused, "", or unknown
|
||||
return !focused
|
||||
}
|
||||
}
|
||||
|
||||
func sequence(mode Mode, message string) string {
|
||||
switch mode {
|
||||
case ModeBell:
|
||||
return "\x07"
|
||||
case ModeNotify:
|
||||
return "\x1b]9;" + sanitizeMessage(message) + "\x07"
|
||||
case ModeBoth:
|
||||
return "\x07\x1b]9;" + sanitizeMessage(message) + "\x07"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether mode will ever emit a notification.
|
||||
func Enabled(mode Mode) bool {
|
||||
return mode != "" && mode != ModeOff
|
||||
}
|
||||
|
||||
// sanitizeMessage drops control bytes (so the message can't break the escape or
|
||||
// inject terminal control) and clamps to maxMessageLen runes.
|
||||
func sanitizeMessage(s string) string {
|
||||
var b strings.Builder
|
||||
count := 0
|
||||
for _, r := range s {
|
||||
if r == 0x1b || r == 0x07 || r < 0x20 || r == 0x7f {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
count++
|
||||
if count >= maxMessageLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShouldEmit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
event Event
|
||||
focused bool
|
||||
want bool
|
||||
}{
|
||||
{"off never", Config{Mode: ModeOff, FocusMode: FocusAlways}, Completion, false, false},
|
||||
{"empty mode never", Config{}, Completion, false, false},
|
||||
{"always when focused", Config{Mode: ModeBell, FocusMode: FocusAlways}, Completion, true, true},
|
||||
{"always when unfocused", Config{Mode: ModeBell, FocusMode: FocusAlways}, Completion, false, true},
|
||||
{"unfocused emits when unfocused", Config{Mode: ModeBell, FocusMode: FocusUnfocused}, Completion, false, true},
|
||||
{"unfocused silent when focused", Config{Mode: ModeBell, FocusMode: FocusUnfocused}, Completion, true, false},
|
||||
{"empty focusmode == unfocused", Config{Mode: ModeBell}, Completion, true, false},
|
||||
{"focused emits when focused", Config{Mode: ModeBell, FocusMode: FocusFocused}, AwaitingInput, true, true},
|
||||
{"focused silent when unfocused", Config{Mode: ModeBell, FocusMode: FocusFocused}, AwaitingInput, false, false},
|
||||
{"awaiting-input also eligible", Config{Mode: ModeBoth, FocusMode: FocusAlways}, AwaitingInput, true, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := shouldEmit(tc.cfg, tc.event, tc.focused); got != tc.want {
|
||||
t.Fatalf("shouldEmit=%v want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequence(t *testing.T) {
|
||||
if got := sequence(ModeBell, "hi"); got != "\x07" {
|
||||
t.Fatalf("bell seq=%q", got)
|
||||
}
|
||||
if got := sequence(ModeNotify, "hi"); got != "\x1b]9;hi\x07" {
|
||||
t.Fatalf("notify seq=%q", got)
|
||||
}
|
||||
if got := sequence(ModeBoth, "hi"); got != "\x07\x1b]9;hi\x07" {
|
||||
t.Fatalf("both seq=%q", got)
|
||||
}
|
||||
if got := sequence(ModeOff, "hi"); got != "" {
|
||||
t.Fatalf("off seq=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessage(t *testing.T) {
|
||||
if got := sanitizeMessage("ok\x1b]0;evil\x07more\nx"); got != "ok]0;evilmorex" {
|
||||
t.Fatalf("sanitize=%q", got)
|
||||
}
|
||||
long := strings.Repeat("a", 500)
|
||||
if got := sanitizeMessage(long); len([]rune(got)) != maxMessageLen {
|
||||
t.Fatalf("clamp len=%d want %d", len([]rune(got)), maxMessageLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyWritesAndRespectsPolicy(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
n := New(&buf, Config{Mode: ModeBoth, FocusMode: FocusAlways})
|
||||
n.Notify(Completion, "Zero: ready")
|
||||
if buf.String() != "\x07\x1b]9;Zero: ready\x07" {
|
||||
t.Fatalf("emitted=%q", buf.String())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
off := New(&buf, Config{Mode: ModeOff})
|
||||
off.Notify(Completion, "x")
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("off should emit nothing, got %q", buf.String())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
uf := New(&buf, Config{Mode: ModeBell, FocusMode: FocusUnfocused})
|
||||
uf.SetFocused(true)
|
||||
uf.Notify(Completion, "x")
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("focused+unfocused-mode should be silent, got %q", buf.String())
|
||||
}
|
||||
uf.SetFocused(false)
|
||||
uf.Notify(Completion, "x")
|
||||
if buf.String() != "\x07" {
|
||||
t.Fatalf("unfocused should bell, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierDefaultFocusFalse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
n := New(&buf, Config{Mode: ModeBell, FocusMode: FocusUnfocused})
|
||||
n.Notify(Completion, "x")
|
||||
if buf.String() != "\x07" {
|
||||
t.Fatalf("default-focus headless should bell, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyRaceSafe(t *testing.T) {
|
||||
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); n.SetFocused(true) }()
|
||||
go func() { defer wg.Done(); n.Notify(Completion, "x") }()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestDefaultMessage(t *testing.T) {
|
||||
if DefaultMessage(Completion) != "Zero: ready" {
|
||||
t.Fatal("completion message")
|
||||
}
|
||||
if DefaultMessage(AwaitingInput) != "Zero: needs input" {
|
||||
t.Fatal("awaiting message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnabled(t *testing.T) {
|
||||
if Enabled(ModeOff) || Enabled("") {
|
||||
t.Fatal("off/empty should be disabled")
|
||||
}
|
||||
if !Enabled(ModeBell) || !Enabled(ModeNotify) || !Enabled(ModeBoth) {
|
||||
t.Fatal("bell/notify/both should be enabled")
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func NormalizePermission(value Permission) Permission {
|
||||
func NormalizeSideEffect(value SideEffect) SideEffect {
|
||||
normalized := SideEffect(strings.ToLower(strings.TrimSpace(string(value))))
|
||||
switch normalized {
|
||||
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace, SideEffectNone:
|
||||
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace:
|
||||
return normalized
|
||||
default:
|
||||
return SideEffectOutOfWorkspace
|
||||
|
||||
@@ -30,13 +30,3 @@ func TestAutonomyAllowedNormalizesBothOperands(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSideEffectPreservesNone(t *testing.T) {
|
||||
if got := NormalizeSideEffect(SideEffectNone); got != SideEffectNone {
|
||||
t.Fatalf("NormalizeSideEffect(none) = %q, want none (must not collapse to out_of_workspace)", got)
|
||||
}
|
||||
// An unrecognized value still fails closed to out_of_workspace.
|
||||
if got := NormalizeSideEffect(SideEffect("bogus")); got != SideEffectOutOfWorkspace {
|
||||
t.Fatalf("NormalizeSideEffect(bogus) = %q, want out_of_workspace", got)
|
||||
}
|
||||
}
|
||||
@@ -83,9 +83,6 @@ func Classify(request Request) Risk {
|
||||
add("network", RiskHigh)
|
||||
case SideEffectOutOfWorkspace:
|
||||
add("out_of_workspace", RiskCritical)
|
||||
case SideEffectNone:
|
||||
// Control-only tool (e.g. escalate_model): no read/write/shell/network
|
||||
// effect, so it contributes no side-effect risk category and stays low.
|
||||
}
|
||||
|
||||
// The bash tool accepts the command under any of these aliases; resolve the
|
||||
|
||||
@@ -219,13 +219,3 @@ func TestClassifyRmLongFlagRootQuotedAndSeparator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyNoneSideEffectIsLowRisk(t *testing.T) {
|
||||
risk := Classify(Request{ToolName: "escalate_model", SideEffect: SideEffectNone})
|
||||
if risk.Level != RiskLow {
|
||||
t.Fatalf("none side-effect risk level = %s, want low", risk.Level)
|
||||
}
|
||||
if HasRiskCategory(risk, "out_of_workspace") {
|
||||
t.Fatalf("control-only tool must not classify as out_of_workspace: %#v", risk)
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,6 @@ const (
|
||||
SideEffectShell SideEffect = "shell"
|
||||
SideEffectNetwork SideEffect = "network"
|
||||
SideEffectOutOfWorkspace SideEffect = "out_of_workspace"
|
||||
// SideEffectNone marks a control-only tool that performs no read/write/
|
||||
// shell/network effect (e.g. escalate_model). It must be recognized so it is
|
||||
// not normalized to out_of_workspace and falsely classified as critical.
|
||||
SideEffectNone SideEffect = "none"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EnsureSpecImplementationInput struct {
|
||||
Title string
|
||||
Cwd string
|
||||
ModelID string
|
||||
Provider string
|
||||
SpecID string
|
||||
SpecFilePath string
|
||||
SpecDraftModelID string
|
||||
SpecDraftReasoning string
|
||||
SpecUserComment string
|
||||
SpecSourceSessionID string
|
||||
RootSessionID string
|
||||
Prompt string
|
||||
}
|
||||
|
||||
func (store *Store) FindSpecImplementation(specID string, sourceSessionID string) (*Metadata, error) {
|
||||
specID = strings.TrimSpace(specID)
|
||||
sourceSessionID = strings.TrimSpace(sourceSessionID)
|
||||
if specID == "" || sourceSessionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !ValidSessionID(sourceSessionID) {
|
||||
return nil, fmt.Errorf("invalid zero session id %q", sourceSessionID)
|
||||
}
|
||||
items, err := store.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.SessionKind == SessionKindSpecImpl && item.SpecID == specID && item.SpecSourceSessionID == sourceSessionID {
|
||||
matched := item
|
||||
return &matched, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (store *Store) EnsureSpecImplementation(input EnsureSpecImplementationInput) (Metadata, []Event, error) {
|
||||
specID := strings.TrimSpace(input.SpecID)
|
||||
sourceSessionID := strings.TrimSpace(input.SpecSourceSessionID)
|
||||
prompt := input.Prompt
|
||||
if specID == "" {
|
||||
return Metadata{}, nil, fmt.Errorf("zero spec id is required")
|
||||
}
|
||||
if sourceSessionID == "" {
|
||||
return Metadata{}, nil, fmt.Errorf("zero spec source session id is required")
|
||||
}
|
||||
if !ValidSessionID(sourceSessionID) {
|
||||
return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sourceSessionID)
|
||||
}
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return Metadata{}, nil, fmt.Errorf("zero spec implementation prompt is required")
|
||||
}
|
||||
source, err := store.Get(sourceSessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, nil, err
|
||||
}
|
||||
if source == nil {
|
||||
return Metadata{}, nil, fmt.Errorf("zero session not found: %s", sourceSessionID)
|
||||
}
|
||||
|
||||
unlock, err := store.lockSession(sourceSessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, nil, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
impl, err := store.FindSpecImplementation(specID, sourceSessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, nil, err
|
||||
}
|
||||
if impl == nil {
|
||||
created, err := store.Create(CreateInput{
|
||||
SessionKind: SessionKindSpecImpl,
|
||||
Title: input.Title,
|
||||
Cwd: input.Cwd,
|
||||
ModelID: input.ModelID,
|
||||
Provider: input.Provider,
|
||||
ParentSessionID: sourceSessionID,
|
||||
RootSessionID: firstNonEmpty(input.RootSessionID, sourceSessionID),
|
||||
SpecID: specID,
|
||||
SpecFilePath: input.SpecFilePath,
|
||||
SpecStatus: SpecStatusApproved,
|
||||
SpecDraftModelID: input.SpecDraftModelID,
|
||||
SpecDraftReasoning: input.SpecDraftReasoning,
|
||||
SpecUserComment: input.SpecUserComment,
|
||||
SpecSourceSessionID: sourceSessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return Metadata{}, nil, err
|
||||
}
|
||||
impl = &created
|
||||
}
|
||||
|
||||
events, err := store.ReadEvents(impl.SessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, nil, err
|
||||
}
|
||||
if !eventsContainUserPrompt(events, prompt) {
|
||||
if _, err := store.AppendEvent(impl.SessionID, AppendEventInput{
|
||||
Type: EventMessage,
|
||||
Payload: map[string]any{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
}); err != nil {
|
||||
return Metadata{}, nil, err
|
||||
}
|
||||
events, err = store.ReadEvents(impl.SessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
loaded, err := store.Get(impl.SessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, nil, err
|
||||
}
|
||||
if loaded == nil {
|
||||
return Metadata{}, nil, fmt.Errorf("zero session not found: %s", impl.SessionID)
|
||||
}
|
||||
return *loaded, events, nil
|
||||
}
|
||||
|
||||
func eventsContainUserPrompt(events []Event, prompt string) bool {
|
||||
for _, event := range events {
|
||||
if event.Type != EventMessage {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if payload.Role == "user" && payload.Content == prompt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -500,70 +500,6 @@ func TestRecordSpecUpdatesMetadataAndAppendsEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSpecImplementationReusesExistingPromptSession(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: sequenceClock([]time.Time{
|
||||
time.Date(2026, 6, 8, 11, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 6, 8, 11, 0, 1, 0, time.UTC),
|
||||
time.Date(2026, 6, 8, 11, 0, 2, 0, time.UTC),
|
||||
time.Date(2026, 6, 8, 11, 0, 3, 0, time.UTC),
|
||||
})})
|
||||
draft, err := store.Create(CreateInput{
|
||||
SessionID: "draft",
|
||||
SessionKind: SessionKindSpecDraft,
|
||||
Title: "Draft spec",
|
||||
Cwd: "/repo",
|
||||
ModelID: "gpt-5",
|
||||
Provider: "openai",
|
||||
SpecID: "2026-06-08-spec",
|
||||
SpecStatus: SpecStatusDraft,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create draft returned error: %v", err)
|
||||
}
|
||||
input := EnsureSpecImplementationInput{
|
||||
Title: "Draft spec implementation",
|
||||
Cwd: draft.Cwd,
|
||||
ModelID: draft.ModelID,
|
||||
Provider: draft.Provider,
|
||||
SpecID: draft.SpecID,
|
||||
SpecFilePath: "/repo/.zero/specs/2026-06-08-spec.md",
|
||||
SpecDraftModelID: "gpt-5",
|
||||
SpecDraftReasoning: "high",
|
||||
SpecUserComment: "ship it",
|
||||
SpecSourceSessionID: draft.SessionID,
|
||||
Prompt: "Implement the approved spec.",
|
||||
}
|
||||
|
||||
first, firstEvents, err := store.EnsureSpecImplementation(input)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureSpecImplementation first returned error: %v", err)
|
||||
}
|
||||
second, secondEvents, err := store.EnsureSpecImplementation(input)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureSpecImplementation second returned error: %v", err)
|
||||
}
|
||||
|
||||
if second.SessionID != first.SessionID {
|
||||
t.Fatalf("implementation session was not reused: first=%s second=%s", first.SessionID, second.SessionID)
|
||||
}
|
||||
if len(firstEvents) != 1 || len(secondEvents) != 1 || second.EventCount != 1 {
|
||||
t.Fatalf("expected one implementation prompt event, first=%#v second=%#v metadata=%#v", firstEvents, secondEvents, second)
|
||||
}
|
||||
items, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
implCount := 0
|
||||
for _, item := range items {
|
||||
if item.SessionKind == SessionKindSpecImpl {
|
||||
implCount++
|
||||
}
|
||||
}
|
||||
if implCount != 1 {
|
||||
t.Fatalf("implementation session count = %d, want 1", implCount)
|
||||
}
|
||||
}
|
||||
|
||||
func fixedClock(value string) func() time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package specmode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func LoadSpecFile(workspaceRoot string, specFilePath string) (string, string, error) {
|
||||
path, err := ResolveSpecFilePath(workspaceRoot, specFilePath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("stat spec file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", "", fmt.Errorf("spec file is not a regular file: %s", path)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read spec file: %w", err)
|
||||
}
|
||||
body := strings.TrimRight(string(data), "\n")
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return "", "", fmt.Errorf("spec file is empty: %s", path)
|
||||
}
|
||||
return body, path, nil
|
||||
}
|
||||
|
||||
func ResolveSpecFilePath(workspaceRoot string, specFilePath string) (string, error) {
|
||||
root := strings.TrimSpace(workspaceRoot)
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("workspace root is required")
|
||||
}
|
||||
absoluteRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve workspace root: %w", err)
|
||||
}
|
||||
path := strings.TrimSpace(specFilePath)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("spec file path is required")
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(absoluteRoot, filepath.FromSlash(path))
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
specDir := filepath.Join(absoluteRoot, filepath.FromSlash(SpecDirName))
|
||||
if err := ensureSpecPathContained(specDir, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func ImplementationPrompt(specBody string, specFilePath string, draftSessionID string, userComment string) string {
|
||||
body := strings.TrimSpace(specBody)
|
||||
lines := []string{
|
||||
"Implement the following approved spec:",
|
||||
"",
|
||||
}
|
||||
if comment := strings.TrimSpace(userComment); comment != "" {
|
||||
lines = append(lines, "User note: "+comment, "")
|
||||
}
|
||||
lines = append(lines, body, "")
|
||||
if path := strings.TrimSpace(specFilePath); path != "" {
|
||||
lines = append(lines, "Spec file: "+path)
|
||||
}
|
||||
if sessionID := strings.TrimSpace(draftSessionID); sessionID != "" {
|
||||
lines = append(lines, "Planning session: "+sessionID)
|
||||
}
|
||||
lines = append(lines,
|
||||
"",
|
||||
"If you need details that are not in the spec, inspect the workspace again. The spec is the approved source of truth for scope.",
|
||||
)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -125,48 +125,6 @@ func TestSubmitToolRejectsInvalidArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSpecFileRejectsPathsOutsideSpecDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(root, "notes.md")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := LoadSpecFile(root, outside)
|
||||
if err == nil {
|
||||
t.Fatal("expected LoadSpecFile to reject a path outside .zero/specs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSpecFileRejectsNonRegularFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, filepath.FromSlash(".zero/specs/not-a-file.md"))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := LoadSpecFile(root, dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("expected non-regular file error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImplementationPromptIncludesReviewContext(t *testing.T) {
|
||||
prompt := ImplementationPrompt("# Goal\n\nShip it.", "/repo/.zero/specs/plan.md", "zero_1", "Keep tests focused.")
|
||||
|
||||
for _, want := range []string{
|
||||
"Implement the following approved spec:",
|
||||
"User note: Keep tests focused.",
|
||||
"# Goal\n\nShip it.",
|
||||
"Spec file: /repo/.zero/specs/plan.md",
|
||||
"Planning session: zero_1",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt missing %q: %s", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fixedSpecTime(value string) func() time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
|
||||
@@ -91,10 +91,8 @@ func (tool editFileTool) Run(_ context.Context, args map[string]any) Result {
|
||||
suffix = "s"
|
||||
}
|
||||
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
|
||||
// Emit the focused change as a unified diff so renderers can show a diff view;
|
||||
// the diff is also clearer confirmation to the model than a prose summary.
|
||||
result := okResult(UnifiedDiff(relativePath, oldString, newString))
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
result.Display = Display{Summary: summary, Kind: "diff"}
|
||||
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff"}
|
||||
return result
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
)
|
||||
|
||||
// escalateToModelMetaKey is the result-metadata key the agent loop reads to
|
||||
// perform a mid-run model switch. The tool itself stays pure: it validates the
|
||||
// upgrade target and signals it here; the loop owns the provider swap.
|
||||
const escalateToModelMetaKey = "escalate_to_model"
|
||||
|
||||
// escalateModelTool lets the agent escalate the current model to its configured
|
||||
// stronger upgrade target for the rest of the run. It is a control action: it
|
||||
// performs no read/write/shell/network effect and only signals the target via
|
||||
// result metadata. The agent loop wires the actual provider switch.
|
||||
type escalateModelTool struct {
|
||||
baseTool
|
||||
registry modelregistry.Registry
|
||||
}
|
||||
|
||||
// NewEscalateModelTool builds the tool with the default model registry. If the
|
||||
// default registry cannot be built the tool degrades gracefully: every run then
|
||||
// reports "no escalation available" rather than failing.
|
||||
func NewEscalateModelTool() Tool {
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
registry = modelregistry.Registry{}
|
||||
}
|
||||
return newEscalateModelTool(registry)
|
||||
}
|
||||
|
||||
// newEscalateModelTool builds the tool with an explicit registry (used by the
|
||||
// exec wiring and tests so escalation resolves against the same catalog the run
|
||||
// uses).
|
||||
func newEscalateModelTool(registry modelregistry.Registry) escalateModelTool {
|
||||
return escalateModelTool{
|
||||
baseTool: baseTool{
|
||||
name: "escalate_model",
|
||||
description: "Escalate the current model to its stronger upgrade target for the rest of this run. Use when the task is harder than expected. No effect if already on the strongest available model.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"reason": {
|
||||
Type: "string",
|
||||
Description: "Optional short note on why a stronger model is needed (recorded for transcript context only).",
|
||||
},
|
||||
},
|
||||
Required: []string{},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: Safety{
|
||||
SideEffect: SideEffectNone,
|
||||
Permission: PermissionAllow,
|
||||
Reason: "Requests a mid-run switch to a stronger model; the agent loop performs the switch.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
},
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Run satisfies the Tool interface for callers that do not supply RunOptions; it
|
||||
// has no current model to escalate from, so it reports no escalation. The real
|
||||
// path is RunWithOptions, which the registry invokes when RunOptions are present.
|
||||
func (tool escalateModelTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
}
|
||||
|
||||
// RunWithOptions reads the current model from options.Model, looks up its upgrade
|
||||
// target, and either signals the target via Meta or returns an informational
|
||||
// result. Status is OK either way (a "no escalation" outcome is not an error).
|
||||
func (tool escalateModelTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
|
||||
// reason is optional and used only for transcript context; reject a non-string
|
||||
// value but accept an absent OR explicitly empty string (allowEmpty) — reason
|
||||
// is informational, so an empty reason must never fail the escalation.
|
||||
if _, err := stringArgWithEmpty(args, "reason", "", false, true); err != nil {
|
||||
return errorResult("Error: Invalid arguments for escalate_model: " + err.Error())
|
||||
}
|
||||
|
||||
current := options.Model
|
||||
target, ok := tool.registry.UpgradeTarget(current)
|
||||
if !ok {
|
||||
label := current
|
||||
if label == "" {
|
||||
label = "current model"
|
||||
}
|
||||
return okResult(fmt.Sprintf("Already using the strongest available model (%s); no escalation performed.", label))
|
||||
}
|
||||
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: fmt.Sprintf("Escalating from %s to %s for the rest of this run.", current, target.ID),
|
||||
Meta: map[string]string{escalateToModelMetaKey: target.ID},
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
)
|
||||
|
||||
func TestEscalateModelToolMetadata(t *testing.T) {
|
||||
tool := NewEscalateModelTool()
|
||||
|
||||
if tool.Name() != "escalate_model" {
|
||||
t.Fatalf("name = %q, want escalate_model", tool.Name())
|
||||
}
|
||||
if tool.Description() == "" {
|
||||
t.Fatal("description must not be empty")
|
||||
}
|
||||
|
||||
safety := tool.Safety()
|
||||
if safety.SideEffect != SideEffectNone {
|
||||
t.Fatalf("side effect = %q, want none", safety.SideEffect)
|
||||
}
|
||||
if safety.Permission != PermissionAllow {
|
||||
t.Fatalf("permission = %q, want allow", safety.Permission)
|
||||
}
|
||||
if !safety.AdvertiseInAuto {
|
||||
t.Fatal("escalate_model must advertise in auto")
|
||||
}
|
||||
|
||||
schema := tool.Parameters()
|
||||
if schema.Type != "object" {
|
||||
t.Fatalf("schema type = %q, want object", schema.Type)
|
||||
}
|
||||
if schema.AdditionalProperties {
|
||||
t.Fatal("schema must disallow additional properties")
|
||||
}
|
||||
if len(schema.Required) != 0 {
|
||||
t.Fatalf("escalate_model must have no required args, got %v", schema.Required)
|
||||
}
|
||||
if _, ok := schema.Properties["reason"]; !ok {
|
||||
t.Fatal("schema must advertise an optional reason property")
|
||||
}
|
||||
}
|
||||
|
||||
// A non-string reason argument is a soft error, not a panic.
|
||||
func TestEscalateModelToolRejectsNonStringReason(t *testing.T) {
|
||||
tool := NewEscalateModelTool()
|
||||
res := tool.Run(context.Background(), map[string]any{"reason": 42})
|
||||
if res.Status != StatusError {
|
||||
t.Fatalf("status = %q, want error for non-string reason", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolving against the real catalog: a known mid-tier model escalates to its
|
||||
// configured stronger target and signals it via Meta["escalate_to_model"].
|
||||
func TestEscalateModelRunSignalsTarget(t *testing.T) {
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry: %v", err)
|
||||
}
|
||||
want, ok := registry.UpgradeTarget("claude-haiku-4.5")
|
||||
if !ok {
|
||||
t.Skip("catalog has no upgrade target seeded for claude-haiku-4.5")
|
||||
}
|
||||
|
||||
tool := newEscalateModelTool(registry)
|
||||
res := tool.RunWithOptions(context.Background(), map[string]any{"reason": "task is harder than expected"}, RunOptions{Model: "claude-haiku-4.5"})
|
||||
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("status = %q, want ok", res.Status)
|
||||
}
|
||||
if got := res.Meta["escalate_to_model"]; got != want.ID {
|
||||
t.Fatalf("meta escalate_to_model = %q, want %q", got, want.ID)
|
||||
}
|
||||
if !strings.Contains(res.Output, "claude-haiku-4.5") || !strings.Contains(res.Output, want.ID) {
|
||||
t.Fatalf("output should name both models, got %q", res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, "Escalating") {
|
||||
t.Fatalf("output should announce escalation, got %q", res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly empty reason (and an absent reason) is accepted: reason is
|
||||
// informational only, so it must never fail the escalation. Both forms still
|
||||
// return the escalation result with the target signalled via Meta.
|
||||
func TestEscalateModelRunAcceptsEmptyReason(t *testing.T) {
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry: %v", err)
|
||||
}
|
||||
target, ok := registry.UpgradeTarget("claude-haiku-4.5")
|
||||
if !ok {
|
||||
t.Skip("catalog has no upgrade target seeded for claude-haiku-4.5")
|
||||
}
|
||||
tool := newEscalateModelTool(registry)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
}{
|
||||
{name: "explicit empty reason", args: map[string]any{"reason": ""}},
|
||||
{name: "absent reason", args: map[string]any{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := tool.RunWithOptions(context.Background(), tc.args, RunOptions{Model: "claude-haiku-4.5"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("status = %q output = %q, want ok (empty reason must not error)", res.Status, res.Output)
|
||||
}
|
||||
if got := res.Meta["escalate_to_model"]; got != target.ID {
|
||||
t.Fatalf("meta escalate_to_model = %q, want %q", got, target.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A top-tier model (no upgrade target), an unknown model, and an empty model all
|
||||
// produce an informational, no-meta, OK result — the loop must not switch.
|
||||
func TestEscalateModelRunNoTarget(t *testing.T) {
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry: %v", err)
|
||||
}
|
||||
tool := newEscalateModelTool(registry)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
model string
|
||||
wantInOut string
|
||||
}{
|
||||
{name: "top-tier", model: "claude-opus-4.1", wantInOut: "claude-opus-4.1"},
|
||||
{name: "unknown", model: "not-a-real-model", wantInOut: "not-a-real-model"},
|
||||
{name: "empty", model: "", wantInOut: "current model"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := tool.RunWithOptions(context.Background(), map[string]any{}, RunOptions{Model: tc.model})
|
||||
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("status = %q, want ok", res.Status)
|
||||
}
|
||||
if _, ok := res.Meta["escalate_to_model"]; ok {
|
||||
t.Fatalf("no-target result must carry no escalate_to_model meta, got %v", res.Meta)
|
||||
}
|
||||
if !strings.Contains(res.Output, "no escalation performed") {
|
||||
t.Fatalf("output should be informational, got %q", res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, tc.wantInOut) {
|
||||
t.Fatalf("output should name %q, got %q", tc.wantInOut, res.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the empty-args path (no reason) is valid and does not switch when on a
|
||||
// top-tier model.
|
||||
func TestEscalateModelRunDefaultsToNoEscalation(t *testing.T) {
|
||||
tool := newEscalateModelTool(modelregistry.Registry{})
|
||||
res := tool.RunWithOptions(context.Background(), map[string]any{}, RunOptions{Model: "claude-opus-4.1"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("status = %q, want ok", res.Status)
|
||||
}
|
||||
if len(res.Meta) != 0 {
|
||||
t.Fatalf("empty registry must never signal a target, got meta %v", res.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end through the Registry: RunWithOptions must route to the tool's
|
||||
// optionsAwareTool branch (threading options.Model) and, because the tool is
|
||||
// PermissionAllow, run without a permission grant.
|
||||
func TestEscalateModelThroughRegistryDispatch(t *testing.T) {
|
||||
modelReg, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry: %v", err)
|
||||
}
|
||||
target, ok := modelReg.UpgradeTarget("claude-haiku-4.5")
|
||||
if !ok {
|
||||
t.Skip("catalog has no upgrade target seeded for claude-haiku-4.5")
|
||||
}
|
||||
|
||||
reg := NewRegistry()
|
||||
reg.Register(newEscalateModelTool(modelReg))
|
||||
|
||||
res := reg.RunWithOptions(context.Background(), "escalate_model", map[string]any{}, RunOptions{
|
||||
Model: "claude-haiku-4.5",
|
||||
})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("status = %q output = %q", res.Status, res.Output)
|
||||
}
|
||||
if got := res.Meta["escalate_to_model"]; got != target.ID {
|
||||
t.Fatalf("meta escalate_to_model = %q, want %q", got, target.ID)
|
||||
}
|
||||
if !strings.Contains(res.Output, target.ID) {
|
||||
t.Fatalf("output should name target %q, got %q", target.ID, res.Output)
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,12 @@ func (tool toolSearchTool) RunWithOptions(_ context.Context, args map[string]any
|
||||
}
|
||||
}
|
||||
|
||||
// deferredTools returns the registry's deferred-eligible tools sorted by name so
|
||||
// keyword ranking and listings are deterministic.
|
||||
func (tool toolSearchTool) deferredTools() []Tool {
|
||||
return tool.visibleDeferredTools(nil, nil)
|
||||
}
|
||||
|
||||
// visibleDeferredTools returns the registry's deferred-eligible tools that pass
|
||||
// the operator allow/deny filters, sorted by name so keyword ranking and
|
||||
// listings stay deterministic. A nil/empty filter pair admits every deferred
|
||||
|
||||
@@ -11,10 +11,8 @@ type Permission string
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// SideEffectNone marks a control-only tool that neither reads nor mutates
|
||||
// state — no read/write/shell/network/out-of-workspace effect. Examples:
|
||||
// tool_search (only reports already-registered tool schemas) and
|
||||
// escalate_model (only requests a loop-level model switch).
|
||||
// SideEffectNone marks tools that neither read nor mutate state (e.g.
|
||||
// tool_search, which only reports already-registered tool schemas).
|
||||
SideEffectNone SideEffect = "none"
|
||||
SideEffectRead SideEffect = "read"
|
||||
SideEffectWrite SideEffect = "write"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
// SideEffectNone marks a control-only tool (no read/write/shell/network/
|
||||
// out-of-workspace effect). escalate_model is its first consumer.
|
||||
func TestSideEffectNoneConstant(t *testing.T) {
|
||||
if SideEffectNone != "none" {
|
||||
t.Fatalf("SideEffectNone = %q, want %q", SideEffectNone, "none")
|
||||
}
|
||||
// It must be distinct from every existing side-effect value.
|
||||
for _, other := range []SideEffect{
|
||||
SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace,
|
||||
} {
|
||||
if SideEffectNone == other {
|
||||
t.Fatalf("SideEffectNone collides with existing side effect %q", other)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package tools
|
||||
|
||||
import "strings"
|
||||
|
||||
// UnifiedDiff returns a line-oriented diff between before and after. Each output
|
||||
// line is prefixed with '+' (added), '-' (removed), or ' ' (unchanged context),
|
||||
// computed from the longest common subsequence of lines. A created file
|
||||
// (before == "") yields all-'+' lines. path is accepted for callers that want to
|
||||
// associate the diff with a file; the body is what the zeroline renderer parses.
|
||||
func UnifiedDiff(path, before, after string) string {
|
||||
a := diffLines(before)
|
||||
b := diffLines(after)
|
||||
m, n := len(a), len(b)
|
||||
|
||||
// lcs[i][j] = length of the LCS of a[i:] and b[j:].
|
||||
lcs := make([][]int, m+1)
|
||||
for i := range lcs {
|
||||
lcs[i] = make([]int, n+1)
|
||||
}
|
||||
for i := m - 1; i >= 0; i-- {
|
||||
for j := n - 1; j >= 0; j-- {
|
||||
if a[i] == b[j] {
|
||||
lcs[i][j] = lcs[i+1][j+1] + 1
|
||||
} else if lcs[i+1][j] >= lcs[i][j+1] {
|
||||
lcs[i][j] = lcs[i+1][j]
|
||||
} else {
|
||||
lcs[i][j] = lcs[i][j+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var out []string
|
||||
i, j := 0, 0
|
||||
for i < m && j < n {
|
||||
switch {
|
||||
case a[i] == b[j]:
|
||||
out = append(out, " "+a[i])
|
||||
i++
|
||||
j++
|
||||
case lcs[i+1][j] >= lcs[i][j+1]:
|
||||
out = append(out, "-"+a[i])
|
||||
i++
|
||||
default:
|
||||
out = append(out, "+"+b[j])
|
||||
j++
|
||||
}
|
||||
}
|
||||
for ; i < m; i++ {
|
||||
out = append(out, "-"+a[i])
|
||||
}
|
||||
for ; j < n; j++ {
|
||||
out = append(out, "+"+b[j])
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// DiffStat counts the added and removed lines in a UnifiedDiff body.
|
||||
func DiffStat(diff string) (add, del int) {
|
||||
if diff == "" {
|
||||
return 0, 0
|
||||
}
|
||||
for _, ln := range strings.Split(diff, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(ln, "+"):
|
||||
add++
|
||||
case strings.HasPrefix(ln, "-"):
|
||||
del++
|
||||
}
|
||||
}
|
||||
return add, del
|
||||
}
|
||||
|
||||
// diffLines splits content into lines, dropping a single trailing newline so a
|
||||
// file that ends in "\n" doesn't produce a spurious trailing empty line.
|
||||
func diffLines(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnifiedDiffNewFile(t *testing.T) {
|
||||
got := UnifiedDiff("hello.txt", "", "hi\nthere")
|
||||
want := "+hi\n+there"
|
||||
if got != want {
|
||||
t.Fatalf("new-file diff = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedDiffEdit(t *testing.T) {
|
||||
before := "line1\nline2\nline3"
|
||||
after := "line1\nline2-changed\nline3"
|
||||
got := UnifiedDiff("f", before, after)
|
||||
for _, want := range []string{" line1", "-line2", "+line2-changed", " line3"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("edit diff missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedDiffInsertion(t *testing.T) {
|
||||
got := UnifiedDiff("f", "a\nb", "a\nx\nb")
|
||||
want := " a\n+x\n b"
|
||||
if got != want {
|
||||
t.Fatalf("insertion diff = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedDiffDeletion(t *testing.T) {
|
||||
got := UnifiedDiff("f", "a\nb\nc", "a\nc")
|
||||
want := " a\n-b\n c"
|
||||
if got != want {
|
||||
t.Fatalf("deletion diff = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedDiffEmpty(t *testing.T) {
|
||||
if got := UnifiedDiff("f", "", ""); got != "" {
|
||||
t.Fatalf("empty diff = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStat(t *testing.T) {
|
||||
add, del := DiffStat(" a\n+x\n+y\n-b\n c")
|
||||
if add != 2 || del != 1 {
|
||||
t.Fatalf("DiffStat = (+%d -%d), want (+2 -1)", add, del)
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,7 @@ func TestSuggestionOverlayRendersInDefaultSkin(t *testing.T) {
|
||||
func TestSuggestionOverlayRendersInZerolineSkin(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Skin: "zeroline", ThemeDark: true})
|
||||
m.width, m.height = 100, 30
|
||||
m.booted = true
|
||||
m.showSplash = false
|
||||
m = typeRunes(t, m, "/mo")
|
||||
|
||||
@@ -240,7 +241,7 @@ func TestFileSuggestionsMatchesAndSkipsVCSDirs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
mustWrite("cmd/server/main.go")
|
||||
mustWrite(".git/config") // hidden VCS dir: must be skipped
|
||||
mustWrite(".git/config") // hidden VCS dir: must be skipped
|
||||
mustWrite("node_modules/dep/index.js") // dependency dir: must be skipped
|
||||
|
||||
got := suggestionTokens(fileSuggestions(root, "main"))
|
||||
|
||||
@@ -25,7 +25,6 @@ const (
|
||||
commandPlan
|
||||
commandSearch
|
||||
commandResume
|
||||
commandSpec
|
||||
commandCompact
|
||||
commandRewind
|
||||
commandEffort
|
||||
@@ -147,13 +146,6 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "List recent sessions or show resume guidance.",
|
||||
kind: commandResume,
|
||||
},
|
||||
{
|
||||
name: "/spec",
|
||||
usage: "/spec <task>",
|
||||
group: commandGroupSession,
|
||||
description: "Draft an implementation spec for review before editing.",
|
||||
kind: commandSpec,
|
||||
},
|
||||
{
|
||||
name: "/compact",
|
||||
usage: "/compact [status]",
|
||||
|
||||
@@ -16,10 +16,10 @@ func TestModelSupportsVisionTUI(t *testing.T) {
|
||||
modelName string
|
||||
want bool
|
||||
}{
|
||||
{modelName: "gpt-4.1", want: true}, // vision model in the catalog
|
||||
{modelName: "claude-sonnet-4.5", want: true}, // vision model in the catalog
|
||||
{modelName: "claude-haiku-3.5", want: true}, // has vision capability
|
||||
{modelName: "totally-unknown-custom", want: false}, // not in catalog -> can't confirm
|
||||
{modelName: "gpt-4.1", want: true}, // vision model in the catalog
|
||||
{modelName: "claude-sonnet-4.5", want: true}, // vision model in the catalog
|
||||
{modelName: "claude-haiku-3.5", want: true}, // has vision capability
|
||||
{modelName: "totally-unknown-custom", want: false}, // not in catalog -> can't confirm
|
||||
{modelName: "", want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -152,6 +152,7 @@ func TestZerolineViewShowsImageChips(t *testing.T) {
|
||||
m.width = 100
|
||||
m.height = 30
|
||||
m.showSplash = false
|
||||
m.booted = true
|
||||
m.pendingImageLabels = []string{"photo.png"}
|
||||
|
||||
view := m.zerolineView()
|
||||
|
||||
+8
-152
@@ -14,12 +14,10 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
"github.com/Gitlawb/zero/internal/zeroline"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -43,7 +41,6 @@ type model struct {
|
||||
usageTracker *usage.Tracker
|
||||
runtimeMessageSink func(tea.Msg)
|
||||
agentOptions agent.Options
|
||||
notifier *notify.Notifier
|
||||
permissionMode agent.PermissionMode
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
responseStyle string
|
||||
@@ -71,7 +68,6 @@ type model struct {
|
||||
flushRunIDs map[int]struct{}
|
||||
pendingPermission *pendingPermissionPrompt
|
||||
pendingAskUser *pendingAskUserPrompt
|
||||
pendingSpecReview *pendingSpecReviewPrompt
|
||||
width int
|
||||
height int
|
||||
now func() time.Time
|
||||
@@ -80,10 +76,7 @@ type model struct {
|
||||
themeVariant int // zeroline color theme (0-4)
|
||||
themeDark bool // zeroline light/dark
|
||||
frame int // animation frame counter (zeroline spinner)
|
||||
jsonMode bool // zeroline TEXT/JSON view toggle (ctrl+j)
|
||||
drawerOpen bool // zeroline sessions drawer (ctrl+s)
|
||||
drawerIndex int // selected session row in the drawer
|
||||
drawerSessions []zeroline.Session
|
||||
booted bool // zeroline boot splash finished
|
||||
streamingText string // live assistant text for the current segment
|
||||
streamStartFrame int // frame the current stream segment began (tok/s)
|
||||
|
||||
@@ -126,7 +119,6 @@ type agentResponseMsg struct {
|
||||
usageEvents []zeroruntime.Usage
|
||||
usageModelID string
|
||||
sessionEvents []pendingSessionEvent
|
||||
specReview *pendingSpecReviewPrompt
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -173,21 +165,6 @@ type pendingAskUserPrompt struct {
|
||||
answers []string
|
||||
}
|
||||
|
||||
type pendingSpecReviewPrompt struct {
|
||||
SpecID string
|
||||
SpecTitle string
|
||||
SpecFilePath string
|
||||
RelativePath string
|
||||
DraftSessionID string
|
||||
}
|
||||
|
||||
type tuiAgentRunOptions struct {
|
||||
registry *tools.Registry
|
||||
permissionMode agent.PermissionMode
|
||||
systemPrompt string
|
||||
specDraft bool
|
||||
}
|
||||
|
||||
func newModel(ctx context.Context, options Options) model {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -229,17 +206,11 @@ func newModel(ctx context.Context, options Options) model {
|
||||
input.Prompt = "zero > "
|
||||
input.Placeholder = "Ask Zero to inspect, edit, explain, or run a command..."
|
||||
if options.Skin == "zeroline" {
|
||||
input.Prompt = "" // the zeroline composer renders its own ❯ prompt
|
||||
input.Placeholder = "describe a task for zero…"
|
||||
input.Prompt = "❯ "
|
||||
input.Placeholder = "message zero — / commands · @ files · ! bash"
|
||||
}
|
||||
input.Focus()
|
||||
|
||||
notifier := notify.New(os.Stderr, notify.Config{
|
||||
Mode: notify.Mode(strings.TrimSpace(options.Notify.Mode)),
|
||||
FocusMode: notify.FocusMode(strings.TrimSpace(options.Notify.FocusMode)),
|
||||
})
|
||||
notifier.SetFocused(true)
|
||||
|
||||
return model{
|
||||
skin: options.Skin,
|
||||
themeVariant: options.ThemeVariant,
|
||||
@@ -265,7 +236,6 @@ func newModel(ctx context.Context, options Options) model {
|
||||
input: input,
|
||||
showSplash: true,
|
||||
now: time.Now,
|
||||
notifier: notifier,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,24 +249,6 @@ func (m model) Init() tea.Cmd {
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
// The sessions drawer is a top-level modal: it intercepts close/navigate
|
||||
// keys and swallows the rest. Ctrl+C (quit/cancel) and a pending permission
|
||||
// always take precedence, so the drawer never traps the user.
|
||||
if m.skin == "zeroline" && m.drawerOpen && m.pendingPermission == nil && msg.Type != tea.KeyCtrlC {
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+s", "q":
|
||||
m.drawerOpen = false
|
||||
case "up", "k":
|
||||
if m.drawerIndex > 0 {
|
||||
m.drawerIndex--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.drawerIndex < len(m.drawerSessions)-1 {
|
||||
m.drawerIndex++
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
// cancelRun records the in-flight run into flushRunIDs and writes the
|
||||
@@ -326,9 +278,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.pendingAskUser != nil {
|
||||
return m.resolveAskUser(true)
|
||||
}
|
||||
if m.pendingSpecReview != nil {
|
||||
return m.cancelSpecReview()
|
||||
}
|
||||
// An open picker cancels first; then an active suggestion overlay is
|
||||
// dismissed. Neither cancels the run or clears the input.
|
||||
if m.picker != nil {
|
||||
@@ -352,9 +301,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.pendingAskUser != nil {
|
||||
return m.submitAskUserAnswer()
|
||||
}
|
||||
if m.pendingSpecReview != nil {
|
||||
return m, nil
|
||||
}
|
||||
if m.picker != nil {
|
||||
return m.choosePicker()
|
||||
}
|
||||
@@ -370,7 +316,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// nextPermissionMode), but only when nothing modal is up: a permission
|
||||
// prompt, ask_user questionnaire, or open picker all take precedence
|
||||
// and let the key fall through to their own handlers below.
|
||||
if m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil && m.picker == nil {
|
||||
if m.pendingPermission == nil && m.pendingAskUser == nil && m.picker == nil {
|
||||
m.permissionMode = nextPermissionMode(m.permissionMode)
|
||||
return m, nil
|
||||
}
|
||||
@@ -405,9 +351,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if m.pendingSpecReview != nil {
|
||||
return m.handleSpecReviewKey(msg)
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.handlePermissionKey(msg)
|
||||
}
|
||||
@@ -418,6 +361,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if m.skin == "zeroline" {
|
||||
m.booted = true // any key dismisses the boot splash
|
||||
if nm, handled := m.handleZerolineKeys(msg); handled {
|
||||
return nm, nil
|
||||
}
|
||||
@@ -428,38 +372,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
m.recomputeSuggestions()
|
||||
return m, cmd
|
||||
case tea.MouseMsg:
|
||||
// Left-click resolves the zeroline permission card by hit-testing its
|
||||
// buttons; the geometry is computed the same way the renderer lays them out.
|
||||
if m.skin == "zeroline" && m.pendingPermission != nil &&
|
||||
msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft {
|
||||
if geo := zeroline.PermLayout(m.width, m.height); geo.Active {
|
||||
switch geo.Hit(msg.X, msg.Y) {
|
||||
case "allow":
|
||||
return m.resolvePermission(permissionDecisionAllow)
|
||||
case "always":
|
||||
return m.resolvePermission(permissionDecisionAlwaysAllow)
|
||||
case "deny":
|
||||
return m.resolvePermission(permissionDecisionDeny)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case tea.FocusMsg:
|
||||
if m.notifier != nil {
|
||||
m.notifier.SetFocused(true)
|
||||
}
|
||||
return m, nil
|
||||
case tea.BlurMsg:
|
||||
if m.notifier != nil {
|
||||
m.notifier.SetFocused(false)
|
||||
}
|
||||
return m, nil
|
||||
case zerolineTickMsg:
|
||||
if m.skin != "zeroline" {
|
||||
return m, nil
|
||||
}
|
||||
m.frame++
|
||||
if m.frame >= bootFrames {
|
||||
m.booted = true
|
||||
}
|
||||
return m, zerolineTick()
|
||||
case agentTextMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
@@ -474,12 +394,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
// Bound the input width for the zeroline composer so a long line scrolls
|
||||
// horizontally instead of growing past the frame and pushing the run/stop
|
||||
// button off the right edge. Leave room for the ❯ prompt + the button.
|
||||
if m.skin == "zeroline" && m.width > 16 {
|
||||
m.input.Width = m.width - 12
|
||||
}
|
||||
return m, nil
|
||||
case permissionRequestMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
@@ -573,12 +487,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
text: msg.err.Error(),
|
||||
})
|
||||
}
|
||||
if msg.specReview != nil {
|
||||
m = m.activateSpecReview(*msg.specReview)
|
||||
}
|
||||
if m.notifier != nil {
|
||||
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
|
||||
}
|
||||
return m, nil
|
||||
case agentRowMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
@@ -637,11 +545,6 @@ func (m model) transcriptView() string {
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if m.pendingSpecReview != nil {
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(renderFocusedSpecReviewPrompt(*m.pendingSpecReview, width))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
builder.WriteString("\n")
|
||||
if chips := renderImageChips(m.pendingImageLabels); chips != "" {
|
||||
@@ -675,9 +578,6 @@ func startsTurn(kind rowKind) bool {
|
||||
}
|
||||
|
||||
func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if msg.String() == "A" { // capital A = always (spec: [a] allow, [A] always, [d] deny)
|
||||
return m.resolvePermission(permissionDecisionAlwaysAllow)
|
||||
}
|
||||
switch strings.ToLower(msg.String()) {
|
||||
case "a":
|
||||
return m.resolvePermission(permissionDecisionAllow)
|
||||
@@ -904,9 +804,6 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
return m, nil
|
||||
case commandSpec:
|
||||
m.showSplash = false
|
||||
return m.handleSpecCommand(command.text)
|
||||
case commandCompact:
|
||||
m.showSplash = false
|
||||
text := ""
|
||||
@@ -1091,28 +988,14 @@ func (m *model) cancelRun() {
|
||||
}
|
||||
|
||||
func (m model) runAgent(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock) tea.Cmd {
|
||||
return m.runAgentWithOptions(runID, runCtx, prompt, images, tuiAgentRunOptions{})
|
||||
}
|
||||
|
||||
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
rows := []transcriptRow{}
|
||||
usageEvents := []zeroruntime.Usage{}
|
||||
sessionEvents := []pendingSessionEvent{}
|
||||
usageModelID := m.modelName
|
||||
var specReview *pendingSpecReviewPrompt
|
||||
options := m.agentOptions
|
||||
options.Registry = m.registry
|
||||
if runOptions.registry != nil {
|
||||
options.Registry = runOptions.registry
|
||||
}
|
||||
options.PermissionMode = m.permissionMode
|
||||
if runOptions.permissionMode != "" {
|
||||
options.PermissionMode = runOptions.permissionMode
|
||||
}
|
||||
if runOptions.systemPrompt != "" {
|
||||
options.SystemPrompt = runOptions.systemPrompt
|
||||
}
|
||||
options.SessionID = m.activeSession.SessionID
|
||||
options.Model = m.modelName
|
||||
options.ReasoningEffort = string(m.reasoningEffort)
|
||||
@@ -1141,9 +1024,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
if m.runtimeMessageSink == nil {
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission prompt unavailable"}, nil
|
||||
}
|
||||
if m.notifier != nil {
|
||||
m.notifier.Notify(notify.AwaitingInput, notify.DefaultMessage(notify.AwaitingInput))
|
||||
}
|
||||
decisionCh := make(chan agent.PermissionDecision, 1)
|
||||
m.sendPermissionRequest(runID, request, func(decision agent.PermissionDecision) {
|
||||
select {
|
||||
@@ -1175,11 +1055,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
// No interactive surface: let the loop degrade gracefully.
|
||||
return agent.AskUserResponse{}, fmt.Errorf("ask_user prompt unavailable")
|
||||
}
|
||||
// Only notify when there is actually something to answer — a request
|
||||
// with no questions auto-resolves without ever prompting the user.
|
||||
if m.notifier != nil && len(request.Questions) > 0 {
|
||||
m.notifier.Notify(notify.AwaitingInput, notify.DefaultMessage(notify.AwaitingInput))
|
||||
}
|
||||
answerCh := make(chan []string, 1)
|
||||
m.sendAskUserRequest(runID, request, func(answers []string) {
|
||||
select {
|
||||
@@ -1246,11 +1121,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
|
||||
onToolResult := options.OnToolResult
|
||||
options.OnToolResult = func(result agent.ToolResult) {
|
||||
if runOptions.specDraft {
|
||||
if info, ok := tuiSpecReviewFromToolResult(result, m.activeSession.SessionID); ok {
|
||||
specReview = &info
|
||||
}
|
||||
}
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: result.ToolCallID,
|
||||
@@ -1270,9 +1140,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
if result.Redacted {
|
||||
toolPayload["redacted"] = true
|
||||
}
|
||||
if len(result.Meta) > 0 {
|
||||
toolPayload["meta"] = result.Meta
|
||||
}
|
||||
if len(result.ChangedFiles) > 0 {
|
||||
toolPayload["changedFiles"] = result.ChangedFiles
|
||||
}
|
||||
@@ -1323,17 +1190,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err}
|
||||
}
|
||||
if runOptions.specDraft {
|
||||
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
|
||||
err := fmt.Errorf("spec draft ended without submit_spec")
|
||||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||||
Type: sessions.EventError,
|
||||
Payload: map[string]any{"message": err.Error()},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err}
|
||||
}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview}
|
||||
}
|
||||
rows = append(rows, transcriptRow{kind: rowAssistant, text: result.FinalAnswer})
|
||||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||||
Type: sessions.EventMessage,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -57,7 +55,6 @@ func TestParseCommand(t *testing.T) {
|
||||
{input: "/find needle", kind: commandSearch, text: "needle"},
|
||||
{input: "/resume", kind: commandResume},
|
||||
{input: "/sessions", kind: commandResume},
|
||||
{input: "/spec add review flow", kind: commandSpec, text: "add review flow"},
|
||||
{input: "/compact", kind: commandCompact},
|
||||
{input: "/effort high", kind: commandEffort, text: "high"},
|
||||
{input: "/style concise", kind: commandStyle, text: "concise"},
|
||||
@@ -89,7 +86,7 @@ func TestCommandRegistryResolvesAliasesAndFormatsHelp(t *testing.T) {
|
||||
}
|
||||
|
||||
help := strings.Join(formatCommandHelpLines(), "\n")
|
||||
for _, want := range []string{"/model", "/context", "/debug", "/permissions", "/spec", "model"} {
|
||||
for _, want := range []string{"/model", "/context", "/debug", "/permissions", "model"} {
|
||||
assertContains(t, help, want)
|
||||
}
|
||||
}
|
||||
@@ -1296,32 +1293,3 @@ func TestNextPermissionModeFoldsUnsafeToAsk(t *testing.T) {
|
||||
t.Fatalf("Unsafe -> %s, want Ask", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelNotifierFocusAndCompletion(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
m := model{notifier: notify.New(&buf, notify.Config{Mode: notify.ModeBell, FocusMode: notify.FocusUnfocused})}
|
||||
m.notifier.SetFocused(true)
|
||||
|
||||
// Focused → completion under unfocused-mode is silent.
|
||||
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("focused should be silent, got %q", buf.String())
|
||||
}
|
||||
|
||||
// BlurMsg flips focus; completion now bells.
|
||||
updated, _ := m.Update(tea.BlurMsg{})
|
||||
m = updated.(model)
|
||||
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
|
||||
if buf.String() != "\x07" {
|
||||
t.Fatalf("unfocused should bell, got %q", buf.String())
|
||||
}
|
||||
|
||||
// FocusMsg flips back.
|
||||
updated, _ = m.Update(tea.FocusMsg{})
|
||||
m = updated.(model)
|
||||
buf.Reset()
|
||||
m.notifier.Notify(notify.Completion, "x")
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("refocused should be silent, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,4 @@ type Options struct {
|
||||
Skin string
|
||||
ThemeVariant int // zeroline color theme index (0-4)
|
||||
ThemeDark bool // zeroline light/dark mode
|
||||
|
||||
// Notify configures completion / awaiting-input notifications.
|
||||
Notify config.NotifyConfig
|
||||
}
|
||||
@@ -187,6 +187,7 @@ func TestPickerRendersInBothSkins(t *testing.T) {
|
||||
// Zeroline skin.
|
||||
z := newModel(context.Background(), Options{Skin: "zeroline", ThemeDark: true, ModelName: "claude-sonnet-4.5"})
|
||||
z.width, z.height = 100, 30
|
||||
z.booted = true
|
||||
z.showSplash = false
|
||||
z.input.SetValue("/model")
|
||||
updatedZ, _ := z.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
+5
-11
@@ -3,11 +3,8 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
)
|
||||
|
||||
// Run starts the Zero Bubble Tea shell and returns a process-style exit code.
|
||||
@@ -28,14 +25,11 @@ func Run(ctx context.Context, options Options) int {
|
||||
tea.WithInput(os.Stdin),
|
||||
tea.WithOutput(os.Stdout),
|
||||
}
|
||||
if notify.Enabled(notify.Mode(strings.TrimSpace(options.Notify.Mode))) {
|
||||
programOpts = append(programOpts, tea.WithReportFocus())
|
||||
}
|
||||
// Enable mouse cell-motion so the permission card's buttons are clickable.
|
||||
// Tradeoff: this routes clicks/drags to the program and can interfere with the
|
||||
// terminal's native text selection (most terminals let users hold Shift/Option
|
||||
// to force native selection). The card stays fully keyboard-driven (a/A/d/Esc).
|
||||
programOpts = append(programOpts, tea.WithMouseCellMotion())
|
||||
// NOTE: we intentionally do NOT enable mouse capture. Mouse cell-motion
|
||||
// reporting routes clicks/drags to the program, which breaks the terminal's
|
||||
// native text selection + copy and surprises users who expect normal
|
||||
// click/select/copy/paste. The permission modal is fully keyboard-driven
|
||||
// (a/y/d/Esc), so capturing the mouse buys little and costs core UX.
|
||||
program = tea.NewProgram(newModel(ctx, options), programOpts...)
|
||||
|
||||
if _, err := program.Run(); err != nil {
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func (m model) handleSpecCommand(task string) (tea.Model, tea.Cmd) {
|
||||
task = strings.TrimSpace(task)
|
||||
if task == "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Usage: /spec <task>"})
|
||||
return m, nil
|
||||
}
|
||||
if m.pending {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "Cannot start spec mode while a run is active."})
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingSpecReview != nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Review the pending spec before drafting another one."})
|
||||
return m, nil
|
||||
}
|
||||
if m.provider == nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendAssistant, text: "No provider configured."})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "/spec " + task})
|
||||
var err error
|
||||
m, err = m.createSpecDraftSession(task)
|
||||
if err != nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "session create error: " + err.Error()})
|
||||
return m, nil
|
||||
}
|
||||
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
|
||||
"role": "user",
|
||||
"content": task,
|
||||
})
|
||||
if err != nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "session record error: " + err.Error()})
|
||||
}
|
||||
|
||||
turnImages := m.pendingImages
|
||||
if len(turnImages) > 0 && !modelSupportsVisionTUI(m.modelName) {
|
||||
name := m.modelName
|
||||
if name == "" {
|
||||
name = "the active model"
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: fmt.Sprintf("Model %s does not support image input; ignoring %d image(s).", name, len(turnImages)),
|
||||
})
|
||||
turnImages = nil
|
||||
}
|
||||
m.pendingImages = nil
|
||||
m.pendingImageLabels = nil
|
||||
|
||||
specRegistry := cloneToolRegistry(m.registry)
|
||||
specmode.RegisterDraftTools(specRegistry, m.cwd, m.now)
|
||||
runCtx, cancel := context.WithCancel(m.ctx)
|
||||
m.runID++
|
||||
m.activeRunID = m.runID
|
||||
m.runCancel = cancel
|
||||
m.pending = true
|
||||
return m, m.runAgentWithOptions(m.activeRunID, runCtx, task, turnImages, tuiAgentRunOptions{
|
||||
registry: specRegistry,
|
||||
permissionMode: agent.PermissionModeSpecDraft,
|
||||
systemPrompt: specmode.DraftSystemPrompt,
|
||||
specDraft: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) createSpecDraftSession(task string) (model, error) {
|
||||
session, err := m.sessionStore.Create(sessions.CreateInput{
|
||||
SessionKind: sessions.SessionKindSpecDraft,
|
||||
Title: tuiSessionTitle(task),
|
||||
Cwd: m.cwd,
|
||||
ModelID: m.modelName,
|
||||
Provider: m.providerName,
|
||||
SpecDraftModelID: m.modelName,
|
||||
SpecDraftReasoning: string(m.reasoningEffort),
|
||||
})
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
m.activeSession = session
|
||||
m.sessionEvents = []sessions.Event{}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func tuiSpecReviewFromToolResult(result agent.ToolResult, draftSessionID string) (pendingSpecReviewPrompt, bool) {
|
||||
if result.Name != specmode.SubmitToolName || result.Meta["control"] != specmode.ControlSpecReviewRequired {
|
||||
return pendingSpecReviewPrompt{}, false
|
||||
}
|
||||
return pendingSpecReviewPrompt{
|
||||
SpecID: strings.TrimSpace(result.Meta["specId"]),
|
||||
SpecTitle: strings.TrimSpace(result.Meta["specTitle"]),
|
||||
SpecFilePath: strings.TrimSpace(result.Meta["specFilePath"]),
|
||||
RelativePath: strings.TrimSpace(result.Meta["relativePath"]),
|
||||
DraftSessionID: strings.TrimSpace(draftSessionID),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (m model) activateSpecReview(review pendingSpecReviewPrompt) model {
|
||||
updated, event, err := m.sessionStore.RecordSpec(review.DraftSessionID, sessions.RecordSpecInput{
|
||||
SpecID: review.SpecID,
|
||||
SpecFilePath: review.SpecFilePath,
|
||||
SpecStatus: sessions.SpecStatusDraft,
|
||||
SpecDraftModelID: m.modelName,
|
||||
SpecDraftReasoning: string(m.reasoningEffort),
|
||||
})
|
||||
if err != nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "spec record error: " + err.Error()})
|
||||
return m
|
||||
}
|
||||
if m.activeSession.SessionID == updated.SessionID {
|
||||
m.activeSession = updated
|
||||
m.sessionEvents = append(m.sessionEvents, event)
|
||||
}
|
||||
m.pendingSpecReview = &review
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: specReviewSummary(review)})
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) handleSpecReviewKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch strings.ToLower(msg.String()) {
|
||||
case "a":
|
||||
return m.approveSpecReview()
|
||||
case "r":
|
||||
return m.rejectSpecReview("")
|
||||
case "e":
|
||||
review := m.pendingSpecReview
|
||||
if review == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "Edit the saved spec file, then press a to approve or r to reject.\npath: " + reviewDisplayPath(*review),
|
||||
})
|
||||
return m, nil
|
||||
case "c":
|
||||
return m.cancelSpecReview()
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) approveSpecReview() (tea.Model, tea.Cmd) {
|
||||
review := m.pendingSpecReview
|
||||
if review == nil {
|
||||
return m, nil
|
||||
}
|
||||
if m.provider == nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendAssistant, text: "No provider configured."})
|
||||
return m, nil
|
||||
}
|
||||
body, path, err := specmode.LoadSpecFile(m.cwd, review.SpecFilePath)
|
||||
if err != nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "spec read error: " + err.Error()})
|
||||
return m, nil
|
||||
}
|
||||
prompt := specmode.ImplementationPrompt(body, path, review.DraftSessionID, "")
|
||||
impl, events, err := m.sessionStore.EnsureSpecImplementation(sessions.EnsureSpecImplementationInput{
|
||||
Title: specImplementationTitle(*review),
|
||||
Cwd: m.cwd,
|
||||
ModelID: m.modelName,
|
||||
Provider: m.providerName,
|
||||
RootSessionID: review.DraftSessionID,
|
||||
SpecID: review.SpecID,
|
||||
SpecFilePath: path,
|
||||
SpecDraftModelID: m.modelName,
|
||||
SpecDraftReasoning: string(m.reasoningEffort),
|
||||
SpecSourceSessionID: review.DraftSessionID,
|
||||
Prompt: prompt,
|
||||
})
|
||||
if err != nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "session create error: " + err.Error()})
|
||||
return m, nil
|
||||
}
|
||||
if _, _, err := m.sessionStore.RecordSpec(review.DraftSessionID, sessions.RecordSpecInput{
|
||||
SpecID: review.SpecID,
|
||||
SpecFilePath: path,
|
||||
SpecStatus: sessions.SpecStatusApproved,
|
||||
SpecDraftModelID: m.modelName,
|
||||
SpecDraftReasoning: string(m.reasoningEffort),
|
||||
SpecImplSessionID: impl.SessionID,
|
||||
}); err != nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "spec approve error: " + err.Error()})
|
||||
return m, nil
|
||||
}
|
||||
m.pendingSpecReview = nil
|
||||
m.activeSession = impl
|
||||
m.sessionEvents = append([]sessions.Event{}, events...)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Spec approved. Starting implementation session " + impl.SessionID + "."})
|
||||
runCtx, cancel := context.WithCancel(m.ctx)
|
||||
m.runID++
|
||||
m.activeRunID = m.runID
|
||||
m.runCancel = cancel
|
||||
m.pending = true
|
||||
return m, m.runAgent(m.activeRunID, runCtx, prompt, nil)
|
||||
}
|
||||
|
||||
func (m model) rejectSpecReview(reason string) (tea.Model, tea.Cmd) {
|
||||
review := m.pendingSpecReview
|
||||
if review == nil {
|
||||
return m, nil
|
||||
}
|
||||
updated, event, err := m.sessionStore.RecordSpec(review.DraftSessionID, sessions.RecordSpecInput{
|
||||
SpecID: review.SpecID,
|
||||
SpecFilePath: review.SpecFilePath,
|
||||
SpecStatus: sessions.SpecStatusRejected,
|
||||
SpecDraftModelID: m.modelName,
|
||||
SpecDraftReasoning: string(m.reasoningEffort),
|
||||
SpecRejectReason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "spec reject error: " + err.Error()})
|
||||
return m, nil
|
||||
}
|
||||
if m.activeSession.SessionID == updated.SessionID {
|
||||
m.activeSession = updated
|
||||
m.sessionEvents = append(m.sessionEvents, event)
|
||||
}
|
||||
m.pendingSpecReview = nil
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Spec rejected. Use /spec <task> to draft again."})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) cancelSpecReview() (tea.Model, tea.Cmd) {
|
||||
m.pendingSpecReview = nil
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Spec review canceled. The draft remains saved."})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func cloneToolRegistry(registry *tools.Registry) *tools.Registry {
|
||||
clone := tools.NewRegistry()
|
||||
if registry == nil {
|
||||
return clone
|
||||
}
|
||||
for _, tool := range registry.All() {
|
||||
clone.Register(tool)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func renderFocusedSpecReviewPrompt(review pendingSpecReviewPrompt, width int) string {
|
||||
lines := []string{
|
||||
zeroTheme.zero.Render("◇ spec review"),
|
||||
"path: " + reviewDisplayPath(review),
|
||||
"[a] approve [r] reject [e] edit file [esc] cancel",
|
||||
}
|
||||
return borderedBlock(width, lines)
|
||||
}
|
||||
|
||||
func specReviewSummary(review pendingSpecReviewPrompt) string {
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Spec draft ready",
|
||||
Status: commandStatusOK,
|
||||
Sections: []commandSection{{
|
||||
Title: "Review",
|
||||
Lines: []string{
|
||||
"spec: " + review.SpecID,
|
||||
"path: " + reviewDisplayPath(review),
|
||||
"keys: a approve, r reject, e edit file, esc cancel",
|
||||
},
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func reviewDisplayPath(review pendingSpecReviewPrompt) string {
|
||||
if strings.TrimSpace(review.RelativePath) != "" {
|
||||
return review.RelativePath
|
||||
}
|
||||
return review.SpecFilePath
|
||||
}
|
||||
|
||||
func specImplementationTitle(review pendingSpecReviewPrompt) string {
|
||||
title := strings.TrimSpace(review.SpecTitle)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(review.SpecID)
|
||||
}
|
||||
if title == "" {
|
||||
return "Spec implementation"
|
||||
}
|
||||
if len(title) > tuiSessionTitleLimit {
|
||||
title = title[:tuiSessionTitleLimit]
|
||||
}
|
||||
return title + " implementation"
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestSpecCommandCreatesDraftReview(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
|
||||
submitSpecScript("call-1", "Review Flow", "# Goal\n\nAdd review flow."),
|
||||
}}
|
||||
m := newSpecModeTestModel(t.TempDir(), provider, store)
|
||||
m.input.SetValue("/spec add review flow")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected /spec to start a draft run")
|
||||
}
|
||||
|
||||
updated, _ = next.Update(cmd())
|
||||
next = updated.(model)
|
||||
|
||||
if next.pendingSpecReview == nil {
|
||||
t.Fatalf("expected pending spec review, got %#v", next)
|
||||
}
|
||||
if next.activeSession.SessionKind != sessions.SessionKindSpecDraft || next.activeSession.SpecStatus != sessions.SpecStatusDraft {
|
||||
t.Fatalf("unexpected active spec session: %#v", next.activeSession)
|
||||
}
|
||||
if !strings.Contains(next.pendingSpecReview.RelativePath, ".zero/specs/") {
|
||||
t.Fatalf("spec path not recorded: %#v", next.pendingSpecReview)
|
||||
}
|
||||
if !providerRequestIncludesTool(provider.requests[0], specmode.SubmitToolName) {
|
||||
t.Fatalf("submit_spec was not advertised: %#v", provider.requests[0].Tools)
|
||||
}
|
||||
if providerRequestIncludesTool(provider.requests[0], "write_file") {
|
||||
t.Fatalf("spec draft must not advertise write_file: %#v", provider.requests[0].Tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecApproveStartsImplementationSession(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
|
||||
submitSpecScript("call-1", "Review Flow", "# Goal\n\nAdd review flow."),
|
||||
textScript("implemented from approved spec"),
|
||||
}}
|
||||
m := newSpecModeTestModel(t.TempDir(), provider, store)
|
||||
m.input.SetValue("/spec add review flow")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(cmd())
|
||||
next = updated.(model)
|
||||
review := next.pendingSpecReview
|
||||
if review == nil {
|
||||
t.Fatal("expected pending review before approval")
|
||||
}
|
||||
|
||||
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")})
|
||||
next = updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected approval to start implementation run")
|
||||
}
|
||||
if next.pendingSpecReview != nil {
|
||||
t.Fatal("expected pending review to clear on approval")
|
||||
}
|
||||
if next.activeSession.SessionKind != sessions.SessionKindSpecImpl {
|
||||
t.Fatalf("expected active implementation session, got %#v", next.activeSession)
|
||||
}
|
||||
|
||||
updated, _ = next.Update(cmd())
|
||||
next = updated.(model)
|
||||
if !transcriptContains(next.transcript, "implemented from approved spec") {
|
||||
t.Fatalf("implementation answer missing from transcript: %#v", next.transcript)
|
||||
}
|
||||
draft, err := store.Get(review.DraftSessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if draft == nil || draft.SpecStatus != sessions.SpecStatusApproved || draft.SpecImplSessionID != next.activeSession.SessionID {
|
||||
t.Fatalf("draft metadata not approved: %#v", draft)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("provider request count = %d, want 2", len(provider.requests))
|
||||
}
|
||||
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(last.Content, "Implement the following approved spec") || !strings.Contains(last.Content, "# Goal") {
|
||||
t.Fatalf("implementation prompt missing spec body: %#v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecReviewBlocksShiftTabModeCycle(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{PermissionMode: agent.PermissionModeAuto})
|
||||
m.pendingSpecReview = &pendingSpecReviewPrompt{SpecID: "spec", SpecFilePath: ".zero/specs/spec.md"}
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||
next := updated.(model)
|
||||
|
||||
if next.permissionMode != agent.PermissionModeAuto {
|
||||
t.Fatalf("expected permission mode unchanged during spec review, got %q", next.permissionMode)
|
||||
}
|
||||
if next.pendingSpecReview == nil {
|
||||
t.Fatal("expected spec review to remain pending")
|
||||
}
|
||||
}
|
||||
|
||||
func newSpecModeTestModel(root string, provider zeroruntime.Provider, store *sessions.Store) model {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
return newModel(context.Background(), Options{
|
||||
Cwd: root,
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-4.1",
|
||||
Provider: provider,
|
||||
Registry: registry,
|
||||
SessionStore: store,
|
||||
PermissionMode: agent.PermissionModeAsk,
|
||||
})
|
||||
}
|
||||
|
||||
func submitSpecScript(callID string, title string, plan string) []zeroruntime.StreamEvent {
|
||||
args, _ := json.Marshal(map[string]string{
|
||||
"title": title,
|
||||
"plan": plan,
|
||||
})
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: specmode.SubmitToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: string(args)},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
}
|
||||
|
||||
func providerRequestIncludesTool(request zeroruntime.CompletionRequest, name string) bool {
|
||||
for _, tool := range request.Tools {
|
||||
if tool.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+33
-104
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +13,10 @@ import (
|
||||
// agent events so "working…" spins smoothly between row updates.
|
||||
type zerolineTickMsg time.Time
|
||||
|
||||
// bootFrames is how many ~120ms ticks the boot splash plays before the home
|
||||
// page reveals (~1.9s), matching the mockup's splash timing.
|
||||
const bootFrames = 16
|
||||
|
||||
func zerolineTick() tea.Cmd {
|
||||
return tea.Tick(120*time.Millisecond, func(t time.Time) tea.Msg { return zerolineTickMsg(t) })
|
||||
}
|
||||
@@ -29,17 +32,6 @@ func (m model) handleZerolineKeys(msg tea.KeyMsg) (model, bool) {
|
||||
case "ctrl+t":
|
||||
m.themeVariant = (m.themeVariant + 1) % len(zeroline.Themes)
|
||||
return m, true
|
||||
case "ctrl+j":
|
||||
m.jsonMode = !m.jsonMode // TEXT/JSON view toggle
|
||||
return m, true
|
||||
case "ctrl+s":
|
||||
if m.showSplash {
|
||||
return m, true // the drawer only renders on the chat page; ignore on home
|
||||
}
|
||||
m.drawerOpen = true // sessions drawer (Esc/ctrl+s closes; handled in Update)
|
||||
m.drawerSessions = m.loadDrawerSessions()
|
||||
m.drawerIndex = 0
|
||||
return m, true
|
||||
}
|
||||
if strings.TrimSpace(m.input.Value()) == "" {
|
||||
if k := msg.String(); len(k) == 1 && k >= "1" && k <= "5" {
|
||||
@@ -50,72 +42,6 @@ func (m model) handleZerolineKeys(msg tea.KeyMsg) (model, bool) {
|
||||
return m, false
|
||||
}
|
||||
|
||||
// drawerData returns the open sessions drawer (nil when closed).
|
||||
func (m model) drawerData() *zeroline.Drawer {
|
||||
if !m.drawerOpen {
|
||||
return nil
|
||||
}
|
||||
return &zeroline.Drawer{Sessions: m.drawerSessions, Selected: m.drawerIndex}
|
||||
}
|
||||
|
||||
// loadDrawerSessions lists real sessions from the store (most-recent first),
|
||||
// falling back to the sample list when the store is empty or unavailable.
|
||||
func (m model) loadDrawerSessions() []zeroline.Session {
|
||||
if m.sessionStore == nil {
|
||||
return zeroline.DefaultSessions()
|
||||
}
|
||||
items, err := m.sessionStore.List()
|
||||
if err != nil || len(items) == 0 {
|
||||
return zeroline.DefaultSessions()
|
||||
}
|
||||
out := make([]zeroline.Session, 0, len(items))
|
||||
for _, it := range items {
|
||||
title := strings.TrimSpace(it.Title)
|
||||
if title == "" {
|
||||
title = "(untitled)"
|
||||
}
|
||||
id := it.SessionID
|
||||
if len(id) > 6 {
|
||||
id = id[:6]
|
||||
}
|
||||
out = append(out, zeroline.Session{
|
||||
ID: id,
|
||||
When: m.relativeWhen(it.UpdatedAt),
|
||||
Title: title,
|
||||
Model: it.ModelID,
|
||||
Turns: it.EventCount,
|
||||
})
|
||||
if len(out) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// relativeWhen formats an RFC3339 timestamp as a short relative age (falls back
|
||||
// to the raw string if it can't be parsed).
|
||||
func (m model) relativeWhen(s string) string {
|
||||
t, err := time.Parse(time.RFC3339, strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
now := time.Now
|
||||
if m.now != nil {
|
||||
now = m.now
|
||||
}
|
||||
d := now().Sub(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
// zerolineHeader builds the zeroline header, including the live cost and
|
||||
// cumulative token total from the usage tracker (with the unpriced fallback) so
|
||||
// the top/bottom bars actually reflect consumption instead of always showing $0.
|
||||
@@ -146,20 +72,32 @@ func (m model) zerolineView() string {
|
||||
height = 30
|
||||
}
|
||||
|
||||
// Spec composer placeholder per surface (m is a value receiver, so this only
|
||||
// affects this render). The composer renders the ❯ prompt + accent caret.
|
||||
if m.showSplash {
|
||||
m.input.Placeholder = "describe a task for zero…"
|
||||
} else {
|
||||
m.input.Placeholder = "message zero…"
|
||||
// Boot splash reveals on launch, then the home page.
|
||||
if !m.booted && m.showSplash {
|
||||
return zeroline.RenderBoot(m.themeVariant, m.themeDark, m.frame, width, height)
|
||||
}
|
||||
|
||||
header := m.zerolineHeader()
|
||||
|
||||
// Home until the first turn is submitted.
|
||||
if m.showSplash {
|
||||
return zeroline.RenderHome(zeroline.HomeData{
|
||||
Variant: m.themeVariant,
|
||||
Dark: m.themeDark,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Header: header,
|
||||
Input: m.input.View(),
|
||||
Suggestions: m.zerolineSuggestions(),
|
||||
SelectedIdx: m.suggestionIdx,
|
||||
Picker: m.zerolinePicker(),
|
||||
})
|
||||
}
|
||||
|
||||
rows := m.zerolineRows()
|
||||
running := false
|
||||
for _, r := range rows {
|
||||
if r.Kind == "tool" && r.Running {
|
||||
if r.Kind == "toolcall" && r.Running {
|
||||
running = true
|
||||
}
|
||||
}
|
||||
@@ -181,10 +119,6 @@ func (m model) zerolineView() string {
|
||||
Stream: m.streamingText,
|
||||
TokS: m.streamTokS(),
|
||||
Spin: m.frame,
|
||||
JSONMode: m.jsonMode,
|
||||
Drawer: m.drawerData(),
|
||||
Chips: zeroline.DefaultChips(), // shown only on the empty state (no rows)
|
||||
ChipIndex: -1,
|
||||
Perm: m.zerolinePerm(),
|
||||
AskUser: askUser,
|
||||
Input: m.input.View(),
|
||||
@@ -270,12 +204,11 @@ func (m model) streamTokS() int {
|
||||
}
|
||||
|
||||
func (m model) zerolineRows() []zeroline.Row {
|
||||
// Merge each tool call with its matching result (by id) into a single "tool"
|
||||
// card row: running until the result arrives, then carrying its status + body.
|
||||
results := make(map[string]transcriptRow)
|
||||
// a tool call is "running" until a result row with the same id arrives
|
||||
resultIDs := make(map[string]bool)
|
||||
for _, r := range m.transcript {
|
||||
if r.kind == rowToolResult && r.id != "" {
|
||||
results[r.id] = r
|
||||
resultIDs[r.id] = true
|
||||
}
|
||||
}
|
||||
rows := make([]zeroline.Row, 0, len(m.transcript))
|
||||
@@ -286,18 +219,14 @@ func (m model) zerolineRows() []zeroline.Row {
|
||||
case rowAssistant:
|
||||
rows = append(rows, zeroline.Row{Kind: "assistant", Text: r.text})
|
||||
case rowToolCall:
|
||||
res, done := results[r.id]
|
||||
row := zeroline.Row{Kind: "tool", Tool: r.tool, Text: r.detail, Running: !(r.id != "" && done)}
|
||||
if done {
|
||||
row.Status = string(res.status)
|
||||
row.Detail = res.detail
|
||||
}
|
||||
rows = append(rows, row)
|
||||
rows = append(rows, zeroline.Row{
|
||||
Kind: "toolcall",
|
||||
Tool: r.tool,
|
||||
Detail: r.detail,
|
||||
Running: !(r.id != "" && resultIDs[r.id]),
|
||||
})
|
||||
case rowToolResult:
|
||||
if r.id != "" {
|
||||
continue // already merged into its tool call above
|
||||
}
|
||||
rows = append(rows, zeroline.Row{Kind: "tool", Tool: r.tool, Status: string(r.status), Detail: r.detail})
|
||||
rows = append(rows, zeroline.Row{Kind: "toolresult", Tool: r.tool, Status: string(r.status), Detail: r.detail})
|
||||
case rowPermission:
|
||||
rows = append(rows, zeroline.Row{Kind: "permission", Text: r.text})
|
||||
case rowAskUser:
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroline"
|
||||
)
|
||||
|
||||
func newZerolineModel() model {
|
||||
@@ -21,6 +20,7 @@ func newZerolineModel() model {
|
||||
ModelName: "claude-sonnet-4.5",
|
||||
})
|
||||
m.width, m.height = 100, 30
|
||||
m.booted = true // skip the boot splash animation; these tests cover home/chat
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestZerolineHomeThenChat(t *testing.T) {
|
||||
m := newZerolineModel()
|
||||
|
||||
// Home is shown until the first turn (showSplash true).
|
||||
if !strings.Contains(m.View(), "std-lib-first") {
|
||||
if !strings.Contains(m.View(), "Own your agent") {
|
||||
t.Fatal("expected Zen home before first turn")
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestZerolineHomeThenChat(t *testing.T) {
|
||||
{kind: rowToolResult, id: "t1", tool: "grep", status: tools.StatusOK, detail: "3 matches"},
|
||||
}
|
||||
chat := m.View()
|
||||
for _, want := range []string{"WORKING", "❯", "grep", "claude-sonnet-4.5", "thinking"} {
|
||||
for _, want := range []string{"WORKING", "you", "grep", "claude-sonnet-4.5", "thinking"} {
|
||||
if !strings.Contains(chat, want) {
|
||||
t.Errorf("chat view missing %q", want)
|
||||
}
|
||||
@@ -91,115 +91,13 @@ func TestZerolinePermissionRender(t *testing.T) {
|
||||
request: agent.PermissionRequest{ToolName: "edit_file", SideEffect: "write"},
|
||||
}
|
||||
out := m.View()
|
||||
for _, want := range []string{"BLOCKED", "PERMISSION", "edit_file", "allow", "deny"} {
|
||||
for _, want := range []string{"BLOCKED", "permission required", "edit_file", "allow", "deny"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("permission view missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZerolinePermissionMouseClick(t *testing.T) {
|
||||
m := newZerolineModel()
|
||||
m.showSplash = false
|
||||
m.pending = true
|
||||
var got []permissionDecision
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: agent.PermissionRequest{ToolName: "edit_file", SideEffect: "write"},
|
||||
decide: func(d agent.PermissionDecision) { got = append(got, permissionDecision(d.Action)) },
|
||||
}
|
||||
geo := zeroline.PermLayout(m.width, m.height)
|
||||
if !geo.Active {
|
||||
t.Fatal("expected active permission geometry at 100x30")
|
||||
}
|
||||
click := tea.MouseMsg{X: geo.Allow.X + geo.Allow.W/2, Y: geo.Allow.Y, Action: tea.MouseActionRelease, Button: tea.MouseButtonLeft}
|
||||
updated, _ := m.Update(click)
|
||||
next := updated.(model)
|
||||
if len(got) != 1 || got[0] != permissionDecisionAllow {
|
||||
t.Fatalf("mouse click on allow should resolve allow, got %#v", got)
|
||||
}
|
||||
if next.pendingPermission != nil {
|
||||
t.Fatal("permission should clear after a resolving click")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZerolineRunningToolSuppressesThinking(t *testing.T) {
|
||||
m := newZerolineModel()
|
||||
m.showSplash = false
|
||||
m.pending = true
|
||||
// A tool call with no matching result → a running card; the separate
|
||||
// "thinking…" line must be suppressed (not stacked under the running card).
|
||||
m.transcript = []transcriptRow{
|
||||
{kind: rowToolCall, id: "t1", tool: "bash", text: "tool call: bash", detail: "go test ./..."},
|
||||
}
|
||||
if out := m.View(); strings.Contains(out, "thinking") {
|
||||
t.Error("a running tool card should suppress the separate 'thinking' line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZerolineDrawerOpenClose(t *testing.T) {
|
||||
m := newZerolineModel()
|
||||
m.showSplash = false
|
||||
// ctrl+s opens the drawer and renders the panel
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
m2 := updated.(model)
|
||||
if !m2.drawerOpen {
|
||||
t.Fatal("ctrl+s should open the sessions drawer")
|
||||
}
|
||||
if !strings.Contains(m2.View(), "sessions") {
|
||||
t.Error("open drawer should render the panel")
|
||||
}
|
||||
// esc closes it
|
||||
updated2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if updated2.(model).drawerOpen {
|
||||
t.Fatal("esc should close the sessions drawer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZerolineDrawerCtrlCNotSwallowed(t *testing.T) {
|
||||
m := newZerolineModel()
|
||||
m.showSplash = false
|
||||
upd, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
m2 := upd.(model)
|
||||
if !m2.drawerOpen {
|
||||
t.Fatal("ctrl+s should open the drawer")
|
||||
}
|
||||
if _, cmd := m2.Update(tea.KeyMsg{Type: tea.KeyCtrlC}); cmd == nil {
|
||||
t.Error("Ctrl+C must not be swallowed by the drawer (it should quit)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZerolineDrawerNotOpenedOnHome(t *testing.T) {
|
||||
m := newZerolineModel()
|
||||
m.showSplash = true // home page
|
||||
upd, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
if upd.(model).drawerOpen {
|
||||
t.Error("ctrl+s on the home page must not open the drawer (nothing renders it there)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZerolineComposerPlaceholderClean(t *testing.T) {
|
||||
m := newZerolineModel()
|
||||
m.input.SetValue("")
|
||||
|
||||
m.showSplash = true
|
||||
home := m.View()
|
||||
if strings.Contains(home, "%") {
|
||||
t.Errorf("home composer must have no %% artifact: %q", home)
|
||||
}
|
||||
if !strings.Contains(home, "describe a task for zero") {
|
||||
t.Error("home placeholder text missing")
|
||||
}
|
||||
|
||||
m.showSplash = false
|
||||
chat := m.View()
|
||||
if strings.Contains(chat, "%") {
|
||||
t.Errorf("chat composer must have no %% artifact: %q", chat)
|
||||
}
|
||||
if !strings.Contains(chat, "message zero") {
|
||||
t.Error("chat placeholder text missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZerolineThemeKeys(t *testing.T) {
|
||||
m := newZerolineModel()
|
||||
// digit selects theme when input empty
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package zeroline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func TestTitleBarSegmentsAndWidth(t *testing.T) {
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
h := Header{Cwd: "~/src/shop", Branch: "main", Dirty: true, Model: "claude-sonnet-4-5"}
|
||||
out := s.topBar("normal", h, 110, false)
|
||||
if lipgloss.Height(out) != 1 {
|
||||
t.Fatalf("title bar must be one row, got %d", lipgloss.Height(out))
|
||||
}
|
||||
if lipgloss.Width(out) != 110 {
|
||||
t.Fatalf("title bar width = %d, want 110 (full width)", lipgloss.Width(out))
|
||||
}
|
||||
plain := stripANSI(out)
|
||||
for _, want := range []string{"zero", "model", "claude-sonnet-4-5", "~/src/shop", "TEXT", "JSON", "main"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("title bar missing %q in %q", want, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleBarHidesCwdWhenTight(t *testing.T) {
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
h := Header{Cwd: "~/src/shop", Branch: "main", Model: "m"}
|
||||
out := s.topBar("normal", h, 48, false)
|
||||
if lipgloss.Width(out) != 48 {
|
||||
t.Fatalf("tight title bar width = %d, want 48", lipgloss.Width(out))
|
||||
}
|
||||
if strings.Contains(stripANSI(out), "~/src/shop") {
|
||||
t.Errorf("cwd must be hidden at tight width, got %q", stripANSI(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusBarSegmentsAndMode(t *testing.T) {
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
h := Header{CtxPct: 42, Cost: 0.04, TotalTokens: 1284}
|
||||
out := s.botBar("work", h, 0, 12, 110)
|
||||
if lipgloss.Height(out) != 1 {
|
||||
t.Fatalf("status bar must be one row, got %d", lipgloss.Height(out))
|
||||
}
|
||||
if lipgloss.Width(out) != 110 {
|
||||
t.Fatalf("status bar width = %d, want 110 (full width)", lipgloss.Width(out))
|
||||
}
|
||||
plain := stripANSI(out)
|
||||
for _, want := range []string{"WORKING", "tok/s", "tok ", "ctx", "$"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("status bar missing %q in %q", want, plain)
|
||||
}
|
||||
}
|
||||
if got := stripANSI(s.botBar("done", h, 0, 0, 110)); !strings.Contains(got, "DONE") {
|
||||
t.Errorf("status bar should show DONE for run=done, got %q", got)
|
||||
}
|
||||
if got := stripANSI(s.botBar("blocked", h, 0, 0, 110)); !strings.Contains(got, "BLOCKED") {
|
||||
t.Errorf("status bar should show BLOCKED for run=blocked, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package zeroline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func TestDrawerRendersSessions(t *testing.T) {
|
||||
d := chatWith([]Row{{Kind: "user", Text: "hi"}, {Kind: "assistant", Text: "hello"}})
|
||||
d.Width, d.Height = 110, 22
|
||||
d.Drawer = &Drawer{Sessions: DefaultSessions(), Selected: 0}
|
||||
out := RenderChat(d)
|
||||
if h := lipgloss.Height(out); h != 22 {
|
||||
t.Fatalf("drawer frame height = %d, want 22 (frame-exact)", h)
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if lipgloss.Width(line) > 110 {
|
||||
t.Fatalf("drawer line exceeds width 110: %d", lipgloss.Width(line))
|
||||
}
|
||||
}
|
||||
plain := stripANSI(out)
|
||||
for _, want := range []string{"sessions", "resume / fork / export", "0a91f3", "Add streaming SSE", "14 turns"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("drawer missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawerSelectionRail(t *testing.T) {
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
panel := strings.Join(s.drawerPanel(&Drawer{Sessions: DefaultSessions(), Selected: 1}, 40, 20), "\n")
|
||||
// the selected (2nd) row carries the accent rail
|
||||
if !strings.Contains(stripANSI(panel), "▌") {
|
||||
t.Error("selected session row should show the accent rail")
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package zeroline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// The empty state renders INSIDE the chat chrome (title bar + status bar +
|
||||
// composer), with the 0 mark + tagline + model hint + chips centered in the body.
|
||||
func TestEmptyStateShowsChromeAndContent(t *testing.T) {
|
||||
d := ChatData{
|
||||
Variant: 0, Dark: true, Width: 90, Height: 30,
|
||||
Header: Header{Model: "claude-sonnet-4-5", Cwd: "~/code/zero"},
|
||||
Chips: []string{"Add a --version flag", "Why is go vet failing?", "Create hello.txt"},
|
||||
}
|
||||
out := RenderChat(d)
|
||||
if h := lipgloss.Height(out); h != 30 {
|
||||
t.Fatalf("empty-state height = %d, want 30 (frame-exact)", h)
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if lipgloss.Width(line) > 90 {
|
||||
t.Fatalf("line exceeds width 90: %d (%q)", lipgloss.Width(line), stripANSI(line))
|
||||
}
|
||||
}
|
||||
plain := stripANSI(out)
|
||||
for _, want := range []string{
|
||||
"zero", // title bar brand
|
||||
"std-lib-first", "running", "against", "claude-sonnet-4-5", // empty-state hint
|
||||
"Add a --version flag", "Why is go vet failing?", "Create hello.txt", // chips
|
||||
"tok", // status bar present (chrome, not a bare Zen page)
|
||||
} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("empty state missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chip selection/border behavior is covered by TestChipBoxBorderedAndSelected.
|
||||
@@ -1,63 +0,0 @@
|
||||
package zeroline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func TestJSONModeRendersEvents(t *testing.T) {
|
||||
d := chatWith([]Row{
|
||||
{Kind: "user", Text: "add a --version flag"},
|
||||
{Kind: "tool", Tool: "edit_file", Text: "cli/root.go", Status: "ok", Detail: "+x"},
|
||||
{Kind: "final", Text: "Done."},
|
||||
{Kind: "done", Text: "1 tool", Status: "ok"},
|
||||
})
|
||||
d.JSONMode = true
|
||||
out := stripANSI(RenderChat(d))
|
||||
for _, want := range []string{
|
||||
`$ `, `zero run`, `--format json`,
|
||||
`"type"`, `"user"`, `"tool_use"`, `"edit_file"`, `"result"`, `"done"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("JSON view missing %q", want)
|
||||
}
|
||||
}
|
||||
// the transcript card border must NOT appear in JSON mode
|
||||
if strings.Contains(out, "╭") {
|
||||
t.Error("JSON mode should not render tool-card borders")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONModeTitleToggle(t *testing.T) {
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
h := Header{Model: "m"}
|
||||
// In text mode the toggle highlights TEXT; in JSON mode it highlights JSON.
|
||||
// Both labels are always present; assert the bar still renders one full row.
|
||||
for _, jm := range []bool{false, true} {
|
||||
out := s.topBar("normal", h, 100, jm)
|
||||
if lipgloss.Height(out) != 1 || lipgloss.Width(out) != 100 {
|
||||
t.Fatalf("title bar not 1x100 (jsonMode=%v): %dx%d", jm, lipgloss.Width(out), lipgloss.Height(out))
|
||||
}
|
||||
plain := stripANSI(out)
|
||||
if !strings.Contains(plain, "TEXT") || !strings.Contains(plain, "JSON") {
|
||||
t.Errorf("toggle missing TEXT/JSON (jsonMode=%v): %q", jm, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONModeFrameExact(t *testing.T) {
|
||||
d := chatWith([]Row{{Kind: "user", Text: "hi"}, {Kind: "assistant", Text: "ok"}})
|
||||
d.JSONMode = true
|
||||
d.Width, d.Height = 100, 20
|
||||
out := RenderChat(d)
|
||||
if h := lipgloss.Height(out); h != 20 {
|
||||
t.Fatalf("JSON frame height = %d, want 20", h)
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if lipgloss.Width(line) > 100 {
|
||||
t.Fatalf("JSON line exceeds width 100: %d", lipgloss.Width(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package zeroline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/ansi"
|
||||
gstyles "github.com/charmbracelet/glamour/styles"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// mdKey identifies a cached glamour renderer. A renderer bakes in the theme
|
||||
// colors AND the word-wrap width, so any change to variant, mode or width needs
|
||||
// a fresh one.
|
||||
type mdKey struct {
|
||||
variant int
|
||||
dark bool
|
||||
width int
|
||||
}
|
||||
|
||||
// mdOutKey caches the RENDERED output per message so glamour runs once per
|
||||
// (message, theme, width) instead of every frame — RenderChat is called on every
|
||||
// spinner tick and keystroke, so re-rendering all assistant markdown each time
|
||||
// would make the TUI sluggish on a long transcript.
|
||||
type mdOutKey struct {
|
||||
text string
|
||||
variant int
|
||||
dark bool
|
||||
width int
|
||||
}
|
||||
|
||||
const mdOutCacheMax = 512 // bounded so a resize drag (many widths) can't grow it forever
|
||||
const mdCacheMax = 256 // ditto for the renderer cache: a resize mints a width key per frame
|
||||
|
||||
var (
|
||||
mdMu sync.Mutex
|
||||
mdCache = map[mdKey]*glamour.TermRenderer{}
|
||||
mdOutput = map[mdOutKey]string{}
|
||||
)
|
||||
|
||||
// renderMarkdown renders CommonMark to styled terminal text using glamour, which
|
||||
// also syntax-highlights fenced code blocks via chroma — markdown + code
|
||||
// highlighting in a single pass. The output is themed to the active palette and
|
||||
// sits on the canvas background so no "card" reappears against the full-bleed
|
||||
// surface. Trailing blank lines glamour appends are trimmed so spacing stays
|
||||
// tight with the surrounding transcript.
|
||||
func renderMarkdown(text string, p Pal, variant int, dark bool, width int) string {
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
key := mdOutKey{text, variant, dark, width}
|
||||
|
||||
mdMu.Lock()
|
||||
if cached, ok := mdOutput[key]; ok {
|
||||
mdMu.Unlock()
|
||||
return cached
|
||||
}
|
||||
mdMu.Unlock()
|
||||
|
||||
// markdownRenderer takes mdMu itself, so it must be called WITHOUT the lock
|
||||
// held (sync.Mutex is not reentrant).
|
||||
r := markdownRenderer(p, variant, dark, width)
|
||||
if r == nil {
|
||||
return text
|
||||
}
|
||||
out, err := r.Render(text)
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
out = strings.Trim(out, "\n")
|
||||
|
||||
mdMu.Lock()
|
||||
if len(mdOutput) >= mdOutCacheMax {
|
||||
mdOutput = map[mdOutKey]string{} // simple bounded reset; cheap to repopulate
|
||||
}
|
||||
mdOutput[key] = out
|
||||
mdMu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// markdownRenderer returns a glamour renderer for the given palette + width,
|
||||
// building and caching one on first use. Cache misses build a renderer keyed by
|
||||
// variant/mode/width so width changes (resizes) get a fresh, correctly wrapped
|
||||
// renderer.
|
||||
func markdownRenderer(p Pal, variant int, dark bool, width int) *glamour.TermRenderer {
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
key := mdKey{variant, dark, width}
|
||||
|
||||
mdMu.Lock()
|
||||
defer mdMu.Unlock()
|
||||
if r, ok := mdCache[key]; ok {
|
||||
return r
|
||||
}
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(themeStyleConfig(p, dark)),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(mdCache) >= mdCacheMax {
|
||||
mdCache = map[mdKey]*glamour.TermRenderer{} // simple bounded reset; cheap to repopulate
|
||||
}
|
||||
mdCache[key] = r
|
||||
return r
|
||||
}
|
||||
|
||||
func sp(s string) *string { return &s }
|
||||
|
||||
// hex returns the lipgloss color as a "#rrggbb" string suitable for glamour /
|
||||
// chroma color fields.
|
||||
func hex(c lipgloss.Color) string { return string(c) }
|
||||
|
||||
// themeStyleConfig derives a glamour StyleConfig from the theme palette. It
|
||||
// starts from glamour's stock dark/light style (so code-block chroma and list
|
||||
// structure stay sensible) and overrides the load-bearing colors with the
|
||||
// theme: headings -> Accent, links -> Accent2, inline code -> readable Fg on the
|
||||
// panel bg, list bullets -> Mute, body text -> Fg on the theme Bg. The document
|
||||
// background is set to the theme Bg to match the full-bleed canvas.
|
||||
func themeStyleConfig(p Pal, dark bool) ansi.StyleConfig {
|
||||
var cfg ansi.StyleConfig
|
||||
if dark {
|
||||
cfg = gstyles.DarkStyleConfig
|
||||
} else {
|
||||
cfg = gstyles.LightStyleConfig
|
||||
}
|
||||
|
||||
bg := hex(p.Bg)
|
||||
|
||||
// Body text + full-bleed background, with no extra block margin so the
|
||||
// transcript's own indent governs alignment.
|
||||
cfg.Document.Color = sp(hex(p.Fg))
|
||||
cfg.Document.BackgroundColor = sp(bg)
|
||||
cfg.Document.BlockPrefix = ""
|
||||
cfg.Document.BlockSuffix = ""
|
||||
zero := uint(0)
|
||||
cfg.Document.Margin = &zero
|
||||
|
||||
// Headings -> Accent, bold, on the theme bg (drop glamour's H1 color block).
|
||||
cfg.Heading.Color = sp(hex(p.Accent))
|
||||
cfg.Heading.BackgroundColor = sp(bg)
|
||||
cfg.H1.Color = sp(hex(p.Accent))
|
||||
cfg.H1.BackgroundColor = sp(bg)
|
||||
cfg.H1.Bold = boolp(true)
|
||||
|
||||
// Links + link text -> Accent2.
|
||||
cfg.Link.Color = sp(hex(p.Accent2))
|
||||
cfg.LinkText.Color = sp(hex(p.Accent2))
|
||||
|
||||
// Inline code -> readable Fg on the panel background.
|
||||
cfg.Code.Color = sp(hex(p.Fg))
|
||||
cfg.Code.BackgroundColor = sp(hex(p.Panel))
|
||||
|
||||
// List bullets / enumeration -> Mute.
|
||||
cfg.Item.Color = sp(hex(p.Mute))
|
||||
cfg.Enumeration.Color = sp(hex(p.Mute))
|
||||
|
||||
// Block quotes + emphasis pick up theme dim/accent.
|
||||
cfg.BlockQuote.Color = sp(hex(p.Mute))
|
||||
cfg.Emph.Color = sp(hex(p.Dim))
|
||||
cfg.Strong.Color = sp(hex(p.Fg))
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func boolp(b bool) *bool { return &b }
|
||||
@@ -3,12 +3,150 @@ package zeroline
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
// forceColor makes lipgloss emit ANSI escapes regardless of the test TTY so we
|
||||
// can assert that styled output is actually colored.
|
||||
func forceColor(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||
}
|
||||
|
||||
func TestRenderMarkdownStripsSyntax(t *testing.T) {
|
||||
p := Resolve(0, true)
|
||||
out := renderMarkdown("**bold** and `code`", p, 0, true, 60)
|
||||
if strings.TrimSpace(out) == "" {
|
||||
t.Fatal("renderMarkdown returned empty output")
|
||||
}
|
||||
if strings.Contains(out, "**") {
|
||||
t.Errorf("markdown markers leaked into output: %q", stripANSI(out))
|
||||
}
|
||||
// the words themselves must survive the render
|
||||
plain := stripANSI(out)
|
||||
for _, w := range []string{"bold", "code"} {
|
||||
if !strings.Contains(plain, w) {
|
||||
t.Errorf("rendered markdown missing %q: %q", w, plain)
|
||||
}
|
||||
}
|
||||
// no trailing blank lines (we Trim them so transcript spacing stays tight)
|
||||
if strings.HasSuffix(out, "\n") || strings.HasPrefix(out, "\n") {
|
||||
t.Errorf("renderMarkdown left surrounding blank lines: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownHighlightsCodeFence(t *testing.T) {
|
||||
p := Resolve(1, true)
|
||||
src := "Here is code:\n\n```go\nfunc main() {}\n```\n"
|
||||
out := renderMarkdown(src, p, 1, true, 70)
|
||||
plain := stripANSI(out)
|
||||
if !strings.Contains(plain, "func main()") {
|
||||
t.Errorf("fenced code body missing from render: %q", plain)
|
||||
}
|
||||
if strings.Contains(plain, "```") {
|
||||
t.Errorf("code fence markers leaked into output: %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownRendererCachedPerKey(t *testing.T) {
|
||||
p := Resolve(2, true)
|
||||
r1 := markdownRenderer(p, 2, true, 80)
|
||||
r2 := markdownRenderer(p, 2, true, 80)
|
||||
if r1 != r2 {
|
||||
t.Error("expected same cached renderer for identical key")
|
||||
}
|
||||
r3 := markdownRenderer(p, 2, true, 81) // width change -> new renderer
|
||||
if r1 == r3 {
|
||||
t.Error("width change should produce a distinct renderer")
|
||||
}
|
||||
r4 := markdownRenderer(p, 3, true, 80) // variant change -> new renderer
|
||||
if r1 == r4 {
|
||||
t.Error("variant change should produce a distinct renderer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownRendererCacheBounded(t *testing.T) {
|
||||
// A resize drag mints a fresh width key on every frame, so the renderer cache
|
||||
// (the expensive goldmark+chroma objects) must be bounded just like the output
|
||||
// cache — otherwise it grows without limit.
|
||||
p := Resolve(0, true)
|
||||
|
||||
mdMu.Lock()
|
||||
mdCache = map[mdKey]*glamour.TermRenderer{}
|
||||
mdMu.Unlock()
|
||||
|
||||
for w := 1; w <= mdCacheMax*3; w++ {
|
||||
markdownRenderer(p, 0, true, w)
|
||||
}
|
||||
|
||||
mdMu.Lock()
|
||||
n := len(mdCache)
|
||||
mdMu.Unlock()
|
||||
if n > mdCacheMax {
|
||||
t.Errorf("mdCache grew past bound: %d entries, want <= %d", n, mdCacheMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorizeDiffColorsAddsAndDels(t *testing.T) {
|
||||
forceColor(t)
|
||||
p := Resolve(0, true)
|
||||
diff := "@@ -1,2 +1,2 @@\n context line\n-removed line\n+added line\n"
|
||||
out := colorizeDiff(diff, p)
|
||||
|
||||
// content survives
|
||||
plain := stripANSI(out)
|
||||
for _, w := range []string{"added line", "removed line", "context line"} {
|
||||
if !strings.Contains(plain, w) {
|
||||
t.Errorf("diff output missing %q: %q", w, plain)
|
||||
}
|
||||
}
|
||||
// a subtle left gutter is present
|
||||
if !strings.Contains(plain, "│") {
|
||||
t.Errorf("diff output missing left gutter: %q", plain)
|
||||
}
|
||||
|
||||
// adds and dels are colored DISTINCTLY: the addition is rendered in the
|
||||
// theme Green and the deletion in the theme Red. The diff line keeps its
|
||||
// leading +/- marker, so we assert against the marked text.
|
||||
green := lipgloss.NewStyle().Foreground(p.Green).Render("+added line")
|
||||
red := lipgloss.NewStyle().Foreground(p.Red).Render("-removed line")
|
||||
if !strings.Contains(out, green) {
|
||||
t.Errorf("added line not colored with theme Green")
|
||||
}
|
||||
if !strings.Contains(out, red) {
|
||||
t.Errorf("removed line not colored with theme Red")
|
||||
}
|
||||
if string(p.Green) == string(p.Red) {
|
||||
t.Fatal("theme 0 unexpectedly uses the same color for green/red")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorizeDiffCapsLongDiffs(t *testing.T) {
|
||||
p := Resolve(0, true)
|
||||
var b strings.Builder
|
||||
b.WriteString("@@ -1,100 +1,100 @@\n")
|
||||
for i := 0; i < 100; i++ {
|
||||
b.WriteString("+line\n")
|
||||
}
|
||||
out := stripANSI(colorizeDiff(b.String(), p))
|
||||
if !strings.Contains(out, "more lines") {
|
||||
t.Errorf("long diff not capped with a footer: %q", out)
|
||||
}
|
||||
// the cap means far fewer than 101 rendered lines
|
||||
if n := strings.Count(out, "\n") + 1; n > diffMaxLines+2 {
|
||||
t.Errorf("diff not capped: %d lines rendered", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeDiff(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"@@ -1 +1 @@\n-a\n+b": true,
|
||||
"--- a\n+++ b\n-x\n+y": true,
|
||||
"@@ -1 +1 @@\n-a\n+b": true,
|
||||
"--- a\n+++ b\n-x\n+y": true,
|
||||
"+just an addition": true,
|
||||
"plain prose with no diff": false,
|
||||
"wrote 12 lines to file.go": false,
|
||||
@@ -20,9 +158,7 @@ func TestLooksLikeDiff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssistantRowRendersPlainMuted(t *testing.T) {
|
||||
// .blk-say is plain MUTED prose (no markdown formatting, no panel): markers are
|
||||
// preserved verbatim rather than rendered through glamour.
|
||||
func TestAssistantRowRendersMarkdown(t *testing.T) {
|
||||
d := ChatData{
|
||||
Variant: 0, Dark: true, Width: 100, Height: 40,
|
||||
Rows: []Row{
|
||||
@@ -30,11 +166,11 @@ func TestAssistantRowRendersPlainMuted(t *testing.T) {
|
||||
},
|
||||
}
|
||||
out := stripANSI(RenderChat(d))
|
||||
if !strings.Contains(out, "**bold**") {
|
||||
t.Errorf("assistant prose should render plainly (keep markers): %q", out)
|
||||
if strings.Contains(out, "**bold**") {
|
||||
t.Errorf("completed assistant markdown not rendered (raw markers present): %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "- one") || !strings.Contains(out, "- two") {
|
||||
t.Errorf("assistant list lines missing: %q", out)
|
||||
if !strings.Contains(out, "bold") {
|
||||
t.Errorf("assistant text missing from render: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package zeroline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// FIX 1: .blk-say is plain MUTED prose with no panel/background; only .blk-final
|
||||
// keeps the accent rail + ink. Glamour formatting would drop the backticks of
|
||||
// inline code, so their survival proves the prose is rendered plainly.
|
||||
func TestAssistantSayIsPlainMuted(t *testing.T) {
|
||||
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "assistant", Text: "use the `flag` package here"}})))
|
||||
if !strings.Contains(out, "`flag`") {
|
||||
t.Errorf("assistant prose must render plainly (keep backticks), got: %q", out)
|
||||
}
|
||||
final := stripANSI(RenderChat(chatWith([]Row{{Kind: "final", Text: "All set."}})))
|
||||
if !strings.Contains(final, "│ All set.") {
|
||||
t.Errorf("final block must keep the accent rail + ink text, got: %q", final)
|
||||
}
|
||||
}
|
||||
|
||||
// FIX 2: each home suggestion is its own bordered rounded box; the selected one
|
||||
// uses the accent (rendered as a thick border so it's visible without color).
|
||||
func TestChipBoxBorderedAndSelected(t *testing.T) {
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
un := stripANSI(s.chipBox("hello world", false, 50))
|
||||
sel := stripANSI(s.chipBox("hello world", true, 50))
|
||||
|
||||
if !strings.Contains(un, "╭") || !strings.Contains(un, "╰") {
|
||||
t.Errorf("unselected chip must be a rounded box, got: %q", un)
|
||||
}
|
||||
if !strings.Contains(un, "❯") || !strings.Contains(un, "hello world") {
|
||||
t.Errorf("chip must carry the accent arrow + label, got: %q", un)
|
||||
}
|
||||
if lipgloss.Width(strings.Split(un, "\n")[0]) != 50 {
|
||||
t.Errorf("chip box must be exactly w wide, got %d", lipgloss.Width(strings.Split(un, "\n")[0]))
|
||||
}
|
||||
if !strings.Contains(sel, "┏") {
|
||||
t.Errorf("selected chip must use the accent (thick) border, got: %q", sel)
|
||||
}
|
||||
if strings.Contains(un, "┏") {
|
||||
t.Errorf("unselected chip must be rounded, not thick, got: %q", un)
|
||||
}
|
||||
}
|
||||
|
||||
// The read_file tool already numbers its lines ("N | …") under a "File: … (N
|
||||
// lines)" header, so the read card must NOT add a second number column or repeat
|
||||
// the header.
|
||||
func TestReadCardNoDoubleNumbering(t *testing.T) {
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
detail := "File: x.py (132 lines)\n\n 1 | import random\n 2 | import time"
|
||||
body := s.toolBody(Row{Kind: "tool", Tool: "read_file", Detail: detail}, 80)
|
||||
joined := stripANSI(strings.Join(body, "\n"))
|
||||
if strings.Contains(joined, "File:") {
|
||||
t.Errorf("read card should drop the redundant File: header: %q", joined)
|
||||
}
|
||||
if len(body) == 0 || stripANSI(body[0]) != " 1 | import random" {
|
||||
t.Errorf("read card should show the tool's own numbering once, got: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved-permission line is colored by outcome (.perm-resolved): allow/always
|
||||
// green ✓, deny red ✗, pending amber ⚠.
|
||||
func TestPermissionLineColoredByOutcome(t *testing.T) {
|
||||
allow := stripANSI(RenderChat(chatWith([]Row{{Kind: "permission", Text: "permission: write_file allow risk:medium"}})))
|
||||
if !strings.Contains(allow, "✓") || strings.Contains(allow, "⚠") {
|
||||
t.Errorf("allowed permission should show ✓ (not ⚠): %q", allow)
|
||||
}
|
||||
deny := stripANSI(RenderChat(chatWith([]Row{{Kind: "permission", Text: "permission: bash deny risk:high"}})))
|
||||
if !strings.Contains(deny, "✗") {
|
||||
t.Errorf("denied permission should show ✗: %q", deny)
|
||||
}
|
||||
pend := stripANSI(RenderChat(chatWith([]Row{{Kind: "permission", Text: "permission: bash prompt risk:high"}})))
|
||||
if !strings.Contains(pend, "⚠") {
|
||||
t.Errorf("pending permission should show ⚠: %q", pend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeChipsAreSeparateBorderedBoxes(t *testing.T) {
|
||||
d := ChatData{
|
||||
Variant: 0, Dark: true, Width: 100, Height: 34, Header: Header{Model: "m"},
|
||||
Chips: []string{"alpha", "beta", "gamma"}, ChipIndex: 1,
|
||||
}
|
||||
out := RenderChat(d)
|
||||
if h := lipgloss.Height(out); h != 34 {
|
||||
t.Fatalf("empty-state height = %d, want 34 (frame-exact)", h)
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if lipgloss.Width(line) > 100 {
|
||||
t.Fatalf("home line exceeds width 100: %d", lipgloss.Width(line))
|
||||
}
|
||||
}
|
||||
plain := stripANSI(out)
|
||||
for _, c := range []string{"alpha", "beta", "gamma"} {
|
||||
if !strings.Contains(plain, "❯ "+c) {
|
||||
t.Errorf("chip %q missing arrow+label", c)
|
||||
}
|
||||
}
|
||||
// selected chip (index 1) renders the accent thick border
|
||||
if !strings.Contains(plain, "┏") {
|
||||
t.Error("home should show the selected chip's accent border")
|
||||
}
|
||||
}
|
||||
+427
-672
File diff suppressed because it is too large.
Load diff
@@ -8,11 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestThemesCount(t *testing.T) {
|
||||
if len(Themes) != 6 {
|
||||
t.Fatalf("expected 6 themes (ZERO + 5 legacy), got %d", len(Themes))
|
||||
}
|
||||
if Themes[0].Name != "ZERO" {
|
||||
t.Fatalf("Themes[0] = %q, want ZERO (the default)", Themes[0].Name)
|
||||
if len(Themes) != 5 {
|
||||
t.Fatalf("expected 5 themes, got %d", len(Themes))
|
||||
}
|
||||
for i, th := range Themes {
|
||||
if th.Name == "" || th.Dark.Bg == "" || th.Light.Bg == "" {
|
||||
@@ -21,15 +18,15 @@ func TestThemesCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyStateAllThemes(t *testing.T) {
|
||||
func TestRenderHomeAllThemes(t *testing.T) {
|
||||
for v := 0; v < len(Themes); v++ {
|
||||
out := RenderChat(ChatData{
|
||||
out := RenderHome(HomeData{
|
||||
Variant: v, Dark: true, Width: 100, Height: 28,
|
||||
Header: Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"},
|
||||
Chips: DefaultChips(),
|
||||
Input: "❯ message zero",
|
||||
})
|
||||
if !strings.Contains(stripANSI(out), "std-lib-first") {
|
||||
t.Errorf("theme %d: empty state missing tagline", v)
|
||||
if !strings.Contains(out, "Own your agent") {
|
||||
t.Errorf("theme %d: home missing tagline", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,12 +37,13 @@ func TestRenderChatLiveData(t *testing.T) {
|
||||
Header: Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"},
|
||||
Rows: []Row{
|
||||
{Kind: "user", Text: "refactor the loop"},
|
||||
{Kind: "tool", Tool: "grep", Text: "pattern: case", Status: "ok", Detail: "internal/x.go:3:case"},
|
||||
{Kind: "toolcall", Tool: "grep", Detail: "pattern: case"},
|
||||
{Kind: "toolresult", Tool: "grep", Status: "ok", Detail: "3 matches"},
|
||||
{Kind: "assistant", Text: "Here is the plan."},
|
||||
},
|
||||
}
|
||||
out := RenderChat(d)
|
||||
for _, want := range []string{"DONE", "❯", "grep", "claude-sonnet-4.5"} {
|
||||
for _, want := range []string{"DONE", "you", "grep", "✦ zero", "claude-sonnet-4.5"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("chat render missing %q", want)
|
||||
}
|
||||
@@ -69,7 +67,7 @@ func TestRenderChatLiveData(t *testing.T) {
|
||||
d.Working = false
|
||||
d.Perm = &Perm{Tool: "edit_file", Risk: "medium", Reason: "writes a file"}
|
||||
p := RenderChat(d)
|
||||
for _, want := range []string{"BLOCKED", "PERMISSION", "edit_file", "allow", "always", "deny"} {
|
||||
for _, want := range []string{"BLOCKED", "permission required", "edit_file", "allow", "always", "deny"} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("permission modal missing %q", want)
|
||||
}
|
||||
@@ -238,24 +236,69 @@ func TestPermLayoutMatchesRenderClamped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCardsShowContent(t *testing.T) {
|
||||
func TestToolResultRenderingCollapsesAndShows(t *testing.T) {
|
||||
d := ChatData{
|
||||
Variant: 0, Dark: true, Width: 100, Height: 40,
|
||||
Rows: []Row{
|
||||
{Kind: "tool", Tool: "read_file", Text: "README.md", Status: "ok", Detail: "line one\nline two\nline three"},
|
||||
{Kind: "tool", Tool: "edit_file", Text: "x.go", Status: "ok", Detail: "@@ -1 +1 @@\n-old\n+NEWCODE"},
|
||||
{Kind: "tool", Tool: "bash", Text: "go test", Status: "error", Detail: "exit 1: BUILD-FAILED-HERE"},
|
||||
{Kind: "toolcall", Tool: "read_file", Detail: "README.md"},
|
||||
{Kind: "toolresult", Tool: "read_file", Status: "ok", Detail: "File: README.md (217 lines)\n\n 1 | THE-RAW-FILE-CONTENT-SHOULD-NOT-APPEAR"},
|
||||
{Kind: "toolcall", Tool: "list_directory", Detail: "."},
|
||||
{Kind: "toolresult", Tool: "list_directory", Status: "ok", Detail: "Contents of .:\n\na\nb\nc"},
|
||||
{Kind: "toolcall", Tool: "edit_file", Detail: "x.go"},
|
||||
{Kind: "toolresult", Tool: "edit_file", Status: "ok", Detail: "@@ -1 +1 @@\n-old\n+NEWCODE"},
|
||||
{Kind: "toolcall", Tool: "bash", Detail: "go test"},
|
||||
{Kind: "toolresult", Tool: "bash", Status: "error", Detail: "exit 1: BUILD-FAILED-HERE"},
|
||||
},
|
||||
}
|
||||
out := stripANSI(RenderChat(d))
|
||||
if !strings.Contains(out, "line one") {
|
||||
t.Error("read card should show file content")
|
||||
// file read collapses to a count, never dumps content
|
||||
if !strings.Contains(out, "217 lines") {
|
||||
t.Error("read_file should summarize to a line count")
|
||||
}
|
||||
if strings.Contains(out, "THE-RAW-FILE-CONTENT-SHOULD-NOT-APPEAR") {
|
||||
t.Error("read_file dumped raw file content (should be collapsed)")
|
||||
}
|
||||
// listing collapses to entry count
|
||||
if !strings.Contains(out, "3 entries") {
|
||||
t.Error("list_directory should summarize to an entry count")
|
||||
}
|
||||
// diff body is shown for edits
|
||||
if !strings.Contains(out, "NEWCODE") {
|
||||
t.Error("edit card should show the diff body")
|
||||
t.Error("edit_file diff body should be shown")
|
||||
}
|
||||
if !strings.Contains(out, "BUILD-FAILED-HERE") || !strings.Contains(out, "✗") {
|
||||
t.Error("bash error card should surface the error output + ✗ status")
|
||||
// errors are surfaced
|
||||
if !strings.Contains(out, "BUILD-FAILED-HERE") {
|
||||
t.Error("error output should be shown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssistantMarkdownClipsLongLines(t *testing.T) {
|
||||
// glamour does not hard-break unbreakable tokens, so a very long URL or fenced
|
||||
// code line would otherwise emit a line far wider than the frame, blowing out
|
||||
// the fixed-height/full-bleed layout. Each emitted line must fit the budget.
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
tw := 60
|
||||
longURL := "See https://example.com/" + strings.Repeat("verylongpath/", 40) + "end"
|
||||
out := s.renderAssistantMarkdown(longURL, tw)
|
||||
if len(out) == 0 {
|
||||
t.Fatal("renderAssistantMarkdown returned no lines")
|
||||
}
|
||||
for i, ln := range out {
|
||||
if w := lipgloss.Width(ln); w > tw {
|
||||
t.Errorf("line %d width %d exceeds budget %d: %q", i, w, tw, stripANSI(ln))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssistantMarkdownClipsLongFencedCode(t *testing.T) {
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
tw := 50
|
||||
src := "```\n" + strings.Repeat("x", 300) + "\n```"
|
||||
out := s.renderAssistantMarkdown(src, tw)
|
||||
for i, ln := range out {
|
||||
if w := lipgloss.Width(ln); w > tw {
|
||||
t.Errorf("line %d width %d exceeds budget %d: %q", i, w, tw, stripANSI(ln))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,6 @@ import "github.com/charmbracelet/lipgloss"
|
||||
type Pal struct {
|
||||
Bg, Panel, Panel2, Fg, Dim, Mute, Line, Line2 lipgloss.Color
|
||||
Accent, Accent2, Green, Amber, Red, Sel lipgloss.Color
|
||||
// Extended fields for the ZERO design: a third panel level, two extra
|
||||
// dim/text levels (faint, faintest), and a blue accent. Legacy themes derive
|
||||
// these in mkPal so new render code always has a non-empty value.
|
||||
Panel3, Faint, Faintest, Blue lipgloss.Color
|
||||
}
|
||||
|
||||
// Theme is a named color identity with a dark and a light variant.
|
||||
@@ -27,35 +23,12 @@ type Theme struct {
|
||||
|
||||
func mkPal(bg, panel, panel2, fg, dim, mute, line, line2, accent, accent2, green, amber, red, sel string) Pal {
|
||||
c := func(s string) lipgloss.Color { return lipgloss.Color(s) }
|
||||
p := Pal{
|
||||
Bg: c(bg), Panel: c(panel), Panel2: c(panel2), Fg: c(fg), Dim: c(dim), Mute: c(mute),
|
||||
Line: c(line), Line2: c(line2), Accent: c(accent), Accent2: c(accent2),
|
||||
Green: c(green), Amber: c(amber), Red: c(red), Sel: c(sel),
|
||||
}
|
||||
// Derive the extended fields for legacy themes from existing colors.
|
||||
p.Panel3 = p.Panel2
|
||||
p.Faint = p.Mute
|
||||
p.Faintest = p.Line2
|
||||
p.Blue = p.Accent2
|
||||
return p
|
||||
}
|
||||
|
||||
// zeroPal is the ZERO design palette (exact hex from the spec). Dark-only design.
|
||||
var zeroPal = Pal{
|
||||
Bg: "#070708", Panel: "#0e0e10", Panel2: "#121215", Panel3: "#17171b",
|
||||
Line: "#242429", Line2: "#2e2e34",
|
||||
Fg: "#ececee", Dim: "#8b8b93", Mute: "#5b5b63", Faint: "#5b5b63", Faintest: "#3a3a40",
|
||||
Accent: "#caff3f", Accent2: "#caff3f", Green: "#5dd1a4", Amber: "#ffc25c", Red: "#ff7a7a",
|
||||
Blue: "#7db4ff", Sel: "#19220a",
|
||||
return Pal{c(bg), c(panel), c(panel2), c(fg), c(dim), c(mute), c(line), c(line2),
|
||||
c(accent), c(accent2), c(green), c(amber), c(red), c(sel)}
|
||||
}
|
||||
|
||||
// Themes is the ordered list of the 5 selectable color identities (keys 1-5).
|
||||
var Themes = []Theme{
|
||||
{
|
||||
Name: "ZERO", Swt: "#caff3f",
|
||||
Dark: zeroPal,
|
||||
Light: zeroPal, // dark-only design
|
||||
},
|
||||
{
|
||||
Name: "Phosphor", Swt: "#ffb000",
|
||||
Dark: mkPal("#040804", "#0a0f0a", "#0e150e", "#dfe8d6", "#8aa07f", "#566b50", "#16241a", "#22382a", "#ffb000", "#36ff7a", "#36ff7a", "#ffb000", "#ff6b6b", "#171f12"),
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package zeroline
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestZeroThemeIsDefault asserts the new ZERO palette is Themes[0] (the Resolve
|
||||
// default) with the exact hex values from the design spec.
|
||||
func TestZeroThemeIsDefault(t *testing.T) {
|
||||
if got := ThemeName(0); got != "ZERO" {
|
||||
t.Fatalf("default theme name = %q, want ZERO", got)
|
||||
}
|
||||
p := Resolve(0, true)
|
||||
got := map[string]string{
|
||||
"Bg": string(p.Bg), "Panel": string(p.Panel), "Panel2": string(p.Panel2),
|
||||
"Panel3": string(p.Panel3), "Line": string(p.Line), "Line2": string(p.Line2),
|
||||
"Fg": string(p.Fg), "Dim": string(p.Dim), "Faint": string(p.Faint),
|
||||
"Faintest": string(p.Faintest), "Accent": string(p.Accent), "Green": string(p.Green),
|
||||
"Red": string(p.Red), "Amber": string(p.Amber), "Blue": string(p.Blue),
|
||||
}
|
||||
want := map[string]string{
|
||||
"Bg": "#070708", "Panel": "#0e0e10", "Panel2": "#121215", "Panel3": "#17171b",
|
||||
"Line": "#242429", "Line2": "#2e2e34", "Fg": "#ececee", "Dim": "#8b8b93",
|
||||
"Faint": "#5b5b63", "Faintest": "#3a3a40", "Accent": "#caff3f", "Green": "#5dd1a4",
|
||||
"Red": "#ff7a7a", "Amber": "#ffc25c", "Blue": "#7db4ff",
|
||||
}
|
||||
for k, w := range want {
|
||||
if got[k] != w {
|
||||
t.Errorf("ZERO.%s = %q, want %q", k, got[k], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyThemesPreserved asserts the 5 original themes remain reachable after
|
||||
// ZERO is prepended, and that the extended Pal fields are populated for them so
|
||||
// new render code never emits an empty color.
|
||||
func TestLegacyThemesPreserved(t *testing.T) {
|
||||
if len(Themes) != 6 {
|
||||
t.Fatalf("len(Themes) = %d, want 6 (ZERO + 5 legacy)", len(Themes))
|
||||
}
|
||||
if got := ThemeName(1); got != "Phosphor" {
|
||||
t.Fatalf("Themes[1] = %q, want Phosphor (legacy preserved)", got)
|
||||
}
|
||||
p := Resolve(1, true)
|
||||
for name, v := range map[string]string{
|
||||
"Panel3": string(p.Panel3), "Faint": string(p.Faint),
|
||||
"Faintest": string(p.Faintest), "Blue": string(p.Blue),
|
||||
} {
|
||||
if v == "" {
|
||||
t.Errorf("legacy theme missing extended field %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ func TestHumanTokens(t *testing.T) {
|
||||
{1000, "1k"}, // .0 trimmed
|
||||
{1234, "1.2k"},
|
||||
{1500, "1.5k"},
|
||||
{999999, "1000k"}, // just under 1M: rounds up but stays in k
|
||||
{1000000, "1M"}, // .0 trimmed at the M boundary
|
||||
{999999, "1000k"}, // just under 1M: rounds up but stays in k
|
||||
{1000000, "1M"}, // .0 trimmed at the M boundary
|
||||
{1500000, "1.5M"},
|
||||
{5000000, "5M"},
|
||||
{12345678, "12.3M"}, // large counts read in M, not thousands of k
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package zeroline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func TestToolCardDiff(t *testing.T) {
|
||||
diff := "+added line\n-removed line\n unchanged"
|
||||
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "edit_file", Text: "internal/cli/root.go", Detail: diff, Status: "ok"}})))
|
||||
if !strings.Contains(out, "╭") || !strings.Contains(out, "╰") {
|
||||
t.Error("tool card missing rounded border")
|
||||
}
|
||||
for _, w := range []string{"edit_file", "internal/cli/root.go", "✓", "added line", "removed line"} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("diff card missing %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCardBash(t *testing.T) {
|
||||
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "bash", Text: "go test ./...", Detail: "ok pkg 0.2s", Status: "ok"}})))
|
||||
for _, w := range []string{"bash", "$ go test", "exit 0"} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("bash card missing %q in %q", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCardBashError(t *testing.T) {
|
||||
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "bash", Text: "go vet", Detail: "vet: bad", Status: "error"}})))
|
||||
if !strings.Contains(out, "✗") || !strings.Contains(out, "exit 1") {
|
||||
t.Errorf("error bash card missing ✗/exit 1: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCardRunningSpinner(t *testing.T) {
|
||||
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "grep", Text: "pattern", Running: true}})))
|
||||
found := false
|
||||
for _, f := range spinFrames {
|
||||
if strings.Contains(out, f) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("running card missing spinner: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCardGrep(t *testing.T) {
|
||||
body := "internal/a.go:41:foo match\ninternal/b.go:12:bar match"
|
||||
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "grep", Text: ".", Detail: body, Status: "ok"}})))
|
||||
for _, w := range []string{"a.go", "foo match", "2 matches"} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("grep card missing %q in %q", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCardGrepTabsNoOverflow(t *testing.T) {
|
||||
body := "a.go:1:if\tx\t{\treturn\tnil}\nb.go:2:foo\tbar\tbaz"
|
||||
d := chatWith([]Row{{Kind: "tool", Tool: "grep", Text: ".", Detail: body, Status: "ok"}})
|
||||
d.Width, d.Height = 60, 16
|
||||
out := RenderChat(d)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if lipgloss.Width(line) > 60 {
|
||||
t.Fatalf("grep line with tabs overflows width 60: %d (%q)", lipgloss.Width(line), stripANSI(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCardLongNameNoHeadOverflow(t *testing.T) {
|
||||
long := "mcp__some_very_long_server__some_very_long_tool_name_indeed"
|
||||
d := chatWith([]Row{{Kind: "tool", Tool: long, Text: "and/a/long/target/path/to/some/file.go", Status: "ok", Detail: "x"}})
|
||||
d.Width, d.Height = 70, 14
|
||||
out := RenderChat(d)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if lipgloss.Width(line) > 70 {
|
||||
t.Fatalf("long tool name overflows card head: %d (%q)", lipgloss.Width(line), stripANSI(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffBodyStatSkipsHeaders(t *testing.T) {
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
diff := "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new"
|
||||
out := stripANSI(strings.Join(s.diffBody(diff, 60), "\n"))
|
||||
if !strings.Contains(out, "+1") || !strings.Contains(out, "-1") {
|
||||
t.Fatalf("stat should be +1/-1 with diff headers excluded, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCardFrameExact(t *testing.T) {
|
||||
d := chatWith([]Row{{Kind: "tool", Tool: "edit_file", Text: "f.go", Detail: "+x\n-y\n z", Status: "ok"}})
|
||||
d.Width, d.Height = 100, 26
|
||||
out := RenderChat(d)
|
||||
if h := lipgloss.Height(out); h != 26 {
|
||||
t.Fatalf("chat height = %d, want 26 (frame-exact)", h)
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if lipgloss.Width(line) > 100 {
|
||||
t.Fatalf("tool-card line exceeds width 100: %d", lipgloss.Width(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package zeroline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func chatWith(rows []Row) ChatData {
|
||||
return ChatData{Variant: 0, Dark: true, Width: 90, Height: 24, Header: Header{Model: "m"}, Rows: rows}
|
||||
}
|
||||
|
||||
func TestTranscriptBlocks(t *testing.T) {
|
||||
// user: accent ❯ + ink, no "you" label
|
||||
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "user", Text: "refactor the loop"}})))
|
||||
if !strings.Contains(out, "❯ refactor the loop") {
|
||||
t.Errorf("user block missing ❯ + text")
|
||||
}
|
||||
if strings.Contains(out, "you ") {
|
||||
t.Errorf("user block should not show the 'you' label")
|
||||
}
|
||||
|
||||
// assistant say: muted text, no "✦ zero" label
|
||||
out = stripANSI(RenderChat(chatWith([]Row{{Kind: "assistant", Text: "Here is the plan."}})))
|
||||
if !strings.Contains(out, "Here is the plan.") {
|
||||
t.Errorf("say text missing")
|
||||
}
|
||||
if strings.Contains(out, "✦ zero") {
|
||||
t.Errorf("say block should not show the zero label")
|
||||
}
|
||||
|
||||
// final: accent rail + ink text
|
||||
out = stripANSI(RenderChat(chatWith([]Row{{Kind: "final", Text: "All done."}})))
|
||||
if !strings.Contains(out, "│") || !strings.Contains(out, "All done.") {
|
||||
t.Errorf("final block missing rail/text: %q", out)
|
||||
}
|
||||
|
||||
// done: ■ + faint meta
|
||||
out = stripANSI(RenderChat(chatWith([]Row{{Kind: "done", Text: "12 tools · 1,284 tok · $0.04", Status: "ok"}})))
|
||||
if !strings.Contains(out, "■") || !strings.Contains(out, "12 tools") {
|
||||
t.Errorf("done block missing dot/meta: %q", out)
|
||||
}
|
||||
|
||||
// notes: sys + deny
|
||||
if out = stripANSI(RenderChat(chatWith([]Row{{Kind: "system", Text: "compacted older turns"}}))); !strings.Contains(out, "compacted older turns") {
|
||||
t.Errorf("sys note missing")
|
||||
}
|
||||
if out = stripANSI(RenderChat(chatWith([]Row{{Kind: "error", Text: "denied: bash"}}))); !strings.Contains(out, "denied: bash") {
|
||||
t.Errorf("deny note missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingCaretOnlyMidStream(t *testing.T) {
|
||||
// Streaming → accent caret ▌ trails the say text.
|
||||
d := chatWith(nil)
|
||||
d.Stream = "partial answer"
|
||||
d.Working = true
|
||||
stream := stripANSI(RenderChat(d))
|
||||
if !strings.Contains(stream, "partial answer") || !strings.Contains(stream, "▌") {
|
||||
t.Errorf("streaming should show text + caret: %q", stream)
|
||||
}
|
||||
// Not streaming → no caret.
|
||||
still := stripANSI(RenderChat(chatWith([]Row{{Kind: "assistant", Text: "settled answer"}})))
|
||||
if strings.Contains(still, "▌") {
|
||||
t.Errorf("non-streaming transcript must not show the caret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiLineNotePreserved(t *testing.T) {
|
||||
// A multi-line system note (e.g. a resume-session summary) must keep all lines.
|
||||
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "system", Text: "Resumed session\nid: abc123\nmodel: opus"}})))
|
||||
for _, want := range []string{"Resumed session", "id: abc123", "model: opus"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("multi-line note dropped %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingPreservesNewlines(t *testing.T) {
|
||||
d := chatWith(nil)
|
||||
d.Stream = "first paragraph\n\nsecond paragraph"
|
||||
d.Working = true
|
||||
out := stripANSI(RenderChat(d))
|
||||
if !strings.Contains(out, "first paragraph") || !strings.Contains(out, "second paragraph") {
|
||||
t.Errorf("streaming flattened line structure: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongTokenReclipped(t *testing.T) {
|
||||
// wrap() does not hard-break a single long word; the block helpers must re-clip.
|
||||
s := newStyles(Resolve(0, true), 0, true)
|
||||
for _, line := range s.renderFinal(strings.Repeat("X", 300), 74, false) {
|
||||
if lipgloss.Width(line) > 76 { // rail "│ " (2) + 74
|
||||
t.Fatalf("renderFinal line overflows budget: %d cells", lipgloss.Width(line))
|
||||
}
|
||||
}
|
||||
for _, line := range s.renderSay(strings.Repeat("Y", 300), 74, false) {
|
||||
if lipgloss.Width(line) > 74 {
|
||||
t.Fatalf("renderSay line overflows budget: %d cells", lipgloss.Width(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerRunStopAffordance(t *testing.T) {
|
||||
idle := stripANSI(RenderChat(chatWith([]Row{{Kind: "user", Text: "hi"}})))
|
||||
if !strings.Contains(idle, "run ↵") {
|
||||
t.Errorf("idle composer should show 'run ↵', got: %q", idle)
|
||||
}
|
||||
d := chatWith([]Row{{Kind: "user", Text: "hi"}})
|
||||
d.Working = true
|
||||
working := stripANSI(RenderChat(d))
|
||||
if !strings.Contains(working, "■ stop") || strings.Contains(working, "run ↵") {
|
||||
t.Errorf("working composer should show '■ stop' not 'run ↵', got: %q", working)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerButtonNotClippedByLongInput(t *testing.T) {
|
||||
d := chatWith([]Row{{Kind: "user", Text: "hi"}})
|
||||
d.Input = strings.Repeat("x", 200)
|
||||
d.Width, d.Height = 50, 12
|
||||
out := stripANSI(RenderChat(d))
|
||||
if !strings.Contains(out, "run ↵") {
|
||||
t.Errorf("run button must survive a long input (was clipped): %q", out)
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if lipgloss.Width(line) > 50 {
|
||||
t.Fatalf("composer line overflows width 50: %d", lipgloss.Width(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptFrameExact(t *testing.T) {
|
||||
d := chatWith([]Row{
|
||||
{Kind: "user", Text: "do the thing"},
|
||||
{Kind: "assistant", Text: "Working on it."},
|
||||
{Kind: "final", Text: "Finished."},
|
||||
{Kind: "done", Text: "2 tools · 100 tok", Status: "ok"},
|
||||
})
|
||||
d.Width, d.Height = 100, 22
|
||||
out := RenderChat(d)
|
||||
if h := lipgloss.Height(out); h != 22 {
|
||||
t.Fatalf("chat height = %d, want 22 (frame-exact)", h)
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if lipgloss.Width(line) > 100 {
|
||||
t.Fatalf("chat line exceeds width 100: %d", lipgloss.Width(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user